正在加载,请稍候…

React Hooks: Common Mistakes and How to Fix Them

Understand and fix the most common React hooks errors — stale closures, useEffect dependencies, infinite re-renders, and misuse of useState with objects.

Why React Hooks Are Tricky

React hooks introduce a new mental model where component state and effects are tied to function calls. Most bugs come from not fully understanding how closures, the dependency array, and React's render cycle interact.

This guide covers the most common mistakes and the fixes for each.

Mistake 1: Stale Closures in useEffect

The most common and hardest-to-debug React hook bug.

// ❌ Bug: count is stale — always 0 inside the interval
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      console.log('Count:', count); // always 0
      setCount(count + 1);         // always sets to 1 (0 + 1)
    }, 1000);
    return () => clearInterval(interval);
  }, []); // [] means "run once" — closes over count=0 forever

  return <div>{count}</div>;
}

Fix 1: Use functional state updater

// ✅ Functional update doesn't need to close over count
useEffect(() => {
  const interval = setInterval(() => {
    setCount(prev => prev + 1);  // always gets current value
  }, 1000);
  return () => clearInterval(interval);
}, []);

Fix 2: Add to dependency array (causes re-registration)

// ✅ Effect re-runs when count changes
useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);  // now uses current count
  }, 1000);
  return () => clearInterval(interval);
}, [count]); // but interval is recreated on every count change

Fix 3: useRef for mutable values

// ✅ ref is always current, doesn't trigger re-render
function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(countRef.current + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return <div>{count}</div>;
}

Mistake 2: Incorrect useEffect Dependencies

ESLint's react-hooks/exhaustive-deps rule flags missing dependencies for a reason.

// ❌ Missing dependency: userId
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // Bug: doesn't refetch when userId changes

  return <div>{user?.name}</div>;
}
// ✅ Include userId in dependencies
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // refetches when userId changes

Object and function dependencies

// ❌ Bug: options object is recreated on every render
function DataFetcher({ userId }) {
  const options = { headers: { 'X-User': userId } }; // new object each render

  useEffect(() => {
    fetch('/api/data', options).then(/* ... */);
  }, [options]); // Infinite loop! options always changes
}

// ✅ Fix 1: Move object inside the effect
useEffect(() => {
  const options = { headers: { 'X-User': userId } };
  fetch('/api/data', options).then(/* ... */);
}, [userId]); // only depends on primitive

// ✅ Fix 2: useMemo to stabilize object
const options = useMemo(
  () => ({ headers: { 'X-User': userId } }),
  [userId]
);

Mistake 3: Infinite Re-render Loops

// ❌ Infinite loop: effect sets state, causing re-render, running effect again
function BadComponent() {
  const [data, setData] = useState([]);

  useEffect(() => {
    setData([...data, 'item']); // triggers re-render → effect runs again
  }); // No dependency array = runs after every render

  return <div>{data.length}</div>;
}
// ❌ Infinite loop: object in deps recreated every render
function AlsoBad() {
  const [result, setResult] = useState(null);
  const config = { timeout: 5000 }; // new object every render

  useEffect(() => {
    fetch('/api', config).then(r => r.json()).then(setResult);
  }, [config]); // config changes every render → infinite
}

// ✅ Fix: useMemo, useCallback, or move values into the effect
const config = useMemo(() => ({ timeout: 5000 }), []); // stable reference

Mistake 4: Mutating State Directly

React state updates only trigger re-renders when you replace the reference, not when you mutate in place.

// ❌ Mutation: React doesn't detect the change
const [items, setItems] = useState(['a', 'b', 'c']);

function addItem() {
  items.push('d'); // mutates the array
  setItems(items); // same reference! React bails out
}

// ✅ Create a new array
function addItem() {
  setItems([...items, 'd']); // new reference → re-render
}

// ❌ Object mutation
const [user, setUser] = useState({ name: 'Alice', age: 30 });
user.name = 'Bob'; // mutating
setUser(user);     // same reference, no re-render

// ✅ Spread to create new object
setUser({ ...user, name: 'Bob' });

Nested object updates

const [profile, setProfile] = useState({
  user: { name: 'Alice', address: { city: 'NYC', zip: '10001' } }
});

// ❌ Bug: nested mutation
profile.user.address.city = 'LA';
setProfile(profile);

// ✅ Deep spread
setProfile({
  ...profile,
  user: {
    ...profile.user,
    address: {
      ...profile.user.address,
      city: 'LA',
    },
  },
});

// ✅ Even better: use Immer
import { produce } from 'immer';

setProfile(produce(draft => {
  draft.user.address.city = 'LA'; // Immer makes mutations safe
}));

Mistake 5: useCallback / useMemo Overuse

// ❌ Pointless memoization — more expensive than doing nothing
function SimpleComponent({ value }) {
  // Wrapping a trivial function wastes memory and CPU
  const add = useCallback((a, b) => a + b, []); // just write: const add = (a, b) => a + b
  const doubled = useMemo(() => value * 2, [value]); // just write: const doubled = value * 2
}

// ✅ When useCallback actually helps: callback passed to a memoized child
const handleClick = useCallback(() => {
  setCount(c => c + 1);
}, []); // stable reference → prevents MemoizedChild from re-rendering

return <MemoizedChild onClick={handleClick} />;

// ✅ When useMemo actually helps: expensive computation
const filteredItems = useMemo(
  () => items.filter(item => item.category === category && item.price < maxPrice),
  [items, category, maxPrice]
); // only recomputes when inputs change, not on every render

Mistake 6: Calling Hooks Conditionally

// ❌ Breaks Rules of Hooks
function BadComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null); // conditional hook call
  }
  // ...
}

// ✅ Always call hooks at top level, use conditions inside
function GoodComponent({ isLoggedIn }) {
  const [user, setUser] = useState(null); // always called

  useEffect(() => {
    if (!isLoggedIn) return; // condition inside the effect
    fetchUser().then(setUser);
  }, [isLoggedIn]);
}

Mistake 7: Not Cleaning Up Effects

// ❌ Memory leak / race condition: state set on unmounted component
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data); // might run after component unmounts
    });
  }, [userId]);
}

// ✅ Cancel fetch on cleanup with AbortController
useEffect(() => {
  const controller = new AbortController();

  fetchUser(userId, { signal: controller.signal })
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err; // ignore abort errors
    });

  return () => controller.abort(); // cleanup: cancel in-flight request
}, [userId]);

Mistake 8: useState with Complex Objects — Consider useReducer

// ❌ Multiple related state updates get out of sync
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

async function loadData() {
  setLoading(true);
  try {
    const result = await fetch('/api');
    setData(result);  // these three updates cause 3 renders
    setLoading(false);
    setError(null);
  } catch (err) {
    setError(err);
    setLoading(false);
  }
}

// ✅ useReducer groups related state updates
const [state, dispatch] = useReducer(reducer, {
  status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
  data: null,
  error: null,
});

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':  return { ...state, status: 'loading', error: null };
    case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null };
    case 'FETCH_ERROR':  return { ...state, status: 'error', error: action.error };
    default: return state;
  }
}

async function loadData() {
  dispatch({ type: 'FETCH_START' });
  try {
    const data = await fetch('/api').then(r => r.json());
    dispatch({ type: 'FETCH_SUCCESS', payload: data }); // one atomic update
  } catch (err) {
    dispatch({ type: 'FETCH_ERROR', error: err });
  }
}

Quick Reference

Bug Root Cause Fix
Stale values in effect Closed over old state Functional updater or correct deps
Effect runs too often Unstable object/function in deps useMemo / useCallback / move into effect
Infinite re-render Object/array in deps recreated each render useMemo or move to outer scope
State change not re-rendering Direct mutation Always replace, never mutate
Race condition on fetch No cleanup AbortController + cleanup fn
Hooks order error Conditional hook call Always call hooks unconditionally

→ Debug your component's state by inspecting data structures with the JSON Viewer.