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.
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?