Avoiding Common Pitfalls
The bugs seniors have already paid for once — stale closures, effect misuse, derived-state duplication.
Most gnarly React bugs are not exotic; they are the same handful of traps, hit over and over. Learn to smell them and you will save yourself hours.
Stale closures
An effect or callback captures the values from the render it was created in. If it runs later, it may read old state. The fix is usually the functional updater or a correct dependency array — not disabling the lint rule.
// BUG: always logs the count from the first render.
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, []); // count is stale hereUsing effects for things effects are not for
You do not need an effect to transform data for rendering — compute it during render (memoize if expensive). You do not need an effect to respond to a user event — do it in the handler. Effects are for synchronizing with external systems: the DOM, subscriptions, network, timers. Reaching for useEffect to sync one piece of state to another is a smell.
Derived state stored in state
Copying a prop into state (const [name, setName] = useState(props.name)) means the copy goes stale when the prop changes. If a value can be computed from props or other state, compute it — do not duplicate it. Duplication is the root of half of all sync bugs.
A few more sharp edges
- Index as key in a list that reorders or filters — corrupts item state and inputs. Use a stable id.
- Mutating state in place (
state.push(x)) — React compares by identity and will not re-render. Always produce a new array/object. - The
0 && <X/>trap — renders a literal0. Coerce to boolean:count > 0 && .... - Fetch without cleanup — a slow response for an old input overwrites the new one. Use an
ignoreflag orAbortController.
Example
import { useState, useEffect } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
// CORRECT effect: synchronizing with an external system (the network),
// with cleanup to discard stale responses.
useEffect(() => {
let ignore = false;
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data); // ignore stale response
})
.catch((err) => {
if (err.name !== 'AbortError') console.error(err);
});
return () => {
ignore = true;
controller.abort();
};
}, [query]); // re-runs correctly when query changes
// Derived value: computed during render, NOT stored in state.
const summary = `${results.length} result(s) for "${query}"`;
return (
<div>
<p>{summary}</p>
<ul>
{results.map((r) => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}When to use it
- A polling effect reads stale state because the closure captures the initial value; switching to a functional updater inside the effect fixes the bug without disabling the lint rule.
- A form derives filtered options in a useEffect that sets state, causing a double render; computing the filtered list directly in the render body eliminates the effect and the flicker.
- An effect that fires on every render because an object literal is in the dependency array is fixed by moving the object creation inside the effect or memoizing it with useMemo.
More examples
Fix stale closure in effects
The functional updater form of setState receives the current state as its argument, bypassing the stale closure that captured the initial value.
// Bug: count is stale after 1 second because the closure captured 0
React.useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always sets to 1
}, 1000);
return () => clearInterval(id);
}, []);
// Fix: functional updater always uses the latest value
React.useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // correct
}, 1000);
return () => clearInterval(id);
}, []);Derive state instead of syncing it
Values that can be computed from existing state are derived state; computing them inline avoids an extra render cycle and removes an unnecessary effect.
// Anti-pattern: effect syncing derived state causes double render
const [items, setItems] = React.useState(allItems);
const [filtered, setFiltered] = React.useState(allItems);
React.useEffect(() => {
setFiltered(items.filter((i) => i.active));
}, [items]);
// Fix: compute during render — no effect needed
const [items, setItems] = React.useState(allItems);
const filtered = items.filter((i) => i.active); // always freshStable object reference in deps
Listing primitive dependencies or memoizing the object gives useEffect a stable reference to compare so it only fires when the values actually change.
// Bug: new object literal on every render triggers effect every render
React.useEffect(() => {
fetchData(options);
}, [options]); // options = { page, limit } created inline above
// Fix A: list the primitive values directly
React.useEffect(() => {
fetchData({ page, limit });
}, [page, limit]);
// Fix B: memoize the object if it must be passed as-is
const options = React.useMemo(() => ({ page, limit }), [page, limit]);
React.useEffect(() => { fetchData(options); }, [options]);
Discussion