React & Next.js

Common React bugs and gotchas

Every bug in this lesson has already been explained mechanically somewhere earlier in this domain — this is the field-reference version, the shape each one actually takes in real code, so it's recognizable on sight instead of requiring the mechanism to be re-derived from scratch every time.

Advanced

5 min read

Bug 1: the stale closure, in its most common real shape

function Timer() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // count here is frozen at whatever it was on the FIRST render — forever
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty array — this effect, and its closure over count, never re-creates
}

Covered mechanically in the dependency-array lesson: the effect's closure captures count's value from the render it was created in, and because the empty dependency array means the effect never re-runs, that captured count never refreshes — every tick computes 0 + 1, forever. The fix, in one line: setCount((prev) => prev + 1) — the functional updater form never reads the outer count at all, sidestepping the stale closure entirely.

Bug 2: the missing-key list bug, showing up as "wrong row has the wrong data"

{items.map((item, index) => (
  <input key={index} defaultValue={item.text} /> // reordering items shows STALE text in the wrong row
))}

Covered mechanically in the keys-and-reconciliation lesson: using the index as key means a reorder changes which index maps to which item, and React — trusting the index — reuses the wrong DOM node's internal state for what it wrongly believes is "the same" element. The fix: key by a real, stable identifier from the data itself (item.id), never by array position.

Bug 3: the infinite effect loop

function Component({ initialItems }) {
  const [items, setItems] = useState([]);
 
  useEffect(() => {
    setItems([...initialItems, { id: "new" }]); // this CREATES a new array reference every run
  }, [items]); // and items is in the dependency array — re-runs the effect... which sets items... forever
}

This is a direct combination of the dependency-array lesson's rules going wrong at once: the effect both reads items (as a dependency) and writes it (via setItems) with a genuinely new array reference every time — each run produces a new array, which is a "changed" dependency by ===, which re-triggers the effect, forever, visible as the browser tab freezing or the console flooding with renders. The fix: don't put a value in the dependency array if the effect's own job is to set that same value — restructure so the effect depends only on genuinely external inputs (initialItems here), not on the state it's producing.

Bug 4: forgetting that setState doesn't update synchronously

function handleClick() {
  setCount(count + 1);
  if (count > 5) { // reads the OLD count — the update from the line above hasn't happened yet
    doSomething();
  }
}

Covered mechanically in the state-and-useState lesson: count inside this function is a fixed snapshot for the entire duration of this render/handler — the if check here is comparing against the old value, not the value setCount just scheduled. The fix: either compute the condition against the new value directly (if (count + 1 > 5)), or move logic that needs the fresh value into a useEffect that depends on count, which runs after the state has actually updated and re-rendered.

Bug 5: an object/array dependency that's recreated every render

function Parent() {
  return <Child options={{ sortBy: "name" }} />; // a NEW object, every single render
}
 
function Child({ options }) {
  useEffect(() => {
    fetchData(options);
  }, [options]); // fires on every Parent render, not just when sortBy actually changes
}

Covered mechanically in the dependency-array lesson: { sortBy: "name" } is structurally identical every render but a genuinely different object reference each time, so the dependency comparison (===) never sees it as "unchanged." The fix: hoist the object outside the component if it's truly constant, wrap it in useMemo if it's derived from something that does change, or depend on the primitive field itself (options.sortBy) instead of the whole object.

Bug 6: mutating state directly instead of creating a new value

function TodoList() {
  const [todos, setTodos] = useState([{ id: 1, done: false }]);
 
  function toggleTodo(id) {
    const todo = todos.find((t) => t.id === id);
    todo.done = !todo.done; // mutates the EXISTING object in place
    setTodos(todos);          // same array reference — React may not even re-render
  }
}

React decides whether to re-render partly by checking whether state actually changed — and for objects/arrays, that check is reference-based, the same === comparison this entire lesson keeps coming back to. Mutating todo.done in place and then calling setTodos with the same array reference can mean React doesn't detect a change at all (no re-render happens), or — if it does re-render for unrelated reasons — the "before" and "after" look identical to anything doing its own reference comparison, like React.memo. The fix: always create a new array/object rather than mutating the existing one — setTodos(todos.map((t) => t.id === id ? { ...t, done: !t.done } : t)) — the standard, idiomatic pattern for updating array/object state immutably.

The actual throughline across all six

Every single one of these bugs traces back to the same handful of mechanisms this domain already covered in depth: reference equality (===) driving dependency comparisons and React.memo, state being a fixed snapshot per render rather than a live, mutable variable, and closures capturing whatever was true at the moment they were created rather than staying "live." Recognizing a bug's shape on sight — "this smells like a stale closure," "this smells like an object-identity dependency issue" — is what separates debugging React quickly from re-deriving these mechanisms from first principles every single time one shows up.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the one-line fix for a stale-closure bug where setCount(count + 1) inside a setInterval always computes from a frozen initial value?

2. What causes an infinite effect loop when an effect both reads a state value as a dependency AND calls its setter with a genuinely new reference every run?

3. Why does mutating a state object directly (todo.done = !todo.done) and then calling setTodos(todos) with the same array reference risk not triggering a re-render at all?