Frontend
React Hooks Deep Dive: useEffect Pitfalls & Custom Hooks
Why the Rules of Hooks exist, what the useEffect dependency array actually means, and how to stop shipping stale-closure bugs.
Hooks look like plain functions, and that's exactly what makes them easy to misuse. Nothing in the syntax of useState or useEffect hints that they rely on being called in a very specific order, or that the array you pass to useEffect means something far more precise than "run this when something changes." Most hook bugs come from treating them like ordinary functions instead of the stateful, order-dependent machinery they actually are.
The Rules of Hooks aren't arbitrary
The Rules of Hooks say: only call hooks at the top level, never inside conditionals, loops, or nested functions. Framed as a rule to memorize, this feels arbitrary. Framed as a consequence of how React tracks hook state, it's obvious.
A component doesn't hold a named variable for each useState call the way you'd expect. Internally, React keeps a linked list of hook entries attached to the component's fiber, and it walks that list in order on every render — the first call to useState reads and writes the first entry, the second call reads and writes the second entry, and so on, purely by position, not by name. There's no other way for React to know that "the third hook call on this render" corresponds to "the third hook call on the last render."
Now put a useState call inside an if block. On a render where the condition is true, that hook occupies slot three. On a render where it's false, slot three belongs to whatever hook comes after it instead — and every hook after that point is now reading someone else's state. React isn't being pedantic when it throws "Rendered fewer hooks than expected"; the list it's walking has genuinely desynced from the list it built last time.
What the dependency array actually means
"Run this effect when these values change" is a comfortable mental model, but it's not what React does, and the gap between the two is where most useEffect bugs live. What actually happens is: after this render commits to the screen, React compares each entry in the dependency array to the corresponding entry from the previous render, using Object.is. If every entry is equal, the effect is skipped. If even one entry differs, the effect body runs again — with whatever cleanup the previous run registered fired first.
The word "change" is doing a lot of hiding here. For primitives — numbers, strings, booleans — reference equality and value equality are the same thing, so the mental model holds up fine. For objects, arrays, and functions, they are not. { id: 1 } created on this render is never Object.is-equal to { id: 1 } created on the last one, even though they look identical. React doesn't compare shape, it compares identity.
The pitfalls that follow from that
Once you take the comparison literally, three of the most common useEffect bugs stop being mysterious.
Stale closures. An effect closes over whatever variables were in scope when it was created on a given render. If that effect reads a value but the value isn't listed as a dependency, the effect keeps re-running with the value it captured the first time — not the current one. A click handler set up once inside an effect that reads a count variable from outside will forever see the count that existed the render it was created, because React never re-created the closure to pick up the new one.
Missing vs. over-eager dependencies. Omitting a dependency the effect actually reads is the classic stale-closure bug above. The opposite mistake — adding dependencies defensively, "to be safe" — trades that bug for a performance one: the effect now re-fires on every render where that value happens to change, even when the change is irrelevant to what the effect is doing, doing extra network calls, subscriptions, or DOM writes that were never needed.
Literal objects and arrays as dependencies. Writing useEffect(() => { ... }, [{ id }]) or passing a freshly-defined callback as a dependency guarantees the array never matches between renders, because a new object or function is allocated every render regardless of whether its contents changed. The effect runs on every single render, dependency array or not — the array is present but it isn't protecting anything.
Reference equality, not value equality
If you find an effect firing more than expected, the first question is never "what changed conceptually" — it's "which dependency got a new reference this render, and why." An object literal, a new array from .map(), or an inline arrow function are the usual suspects.
Cleanup: not just for unmounting
The function an effect returns isn't only invoked when the component unmounts — that's the easy case people remember. React also calls it immediately before running the effect again, on every re-render where a dependency changed. That ordering — cleanup, then the new effect — is what keeps effects idempotent across renders instead of accumulating side effects.
Skip the cleanup on something like a WebSocket subscription or a window.addEventListener call, and every re-render that changes the dependency adds another listener on top of the last one instead of replacing it. The bug doesn't show up immediately — the component looks fine on first render — it shows up as a slow leak: duplicate event handlers firing multiple times per event, extra network sockets that never close, or a component that keeps working after it's already unmounted because nothing ever tore down its subscription.
useMemo and useCallback: buying referential stability, not speed
useMemo and useCallback get reached for as general-purpose performance tools, but that's not really what they're for. Recomputing a simple value or re-creating a small function on every render is almost never the bottleneck — JavaScript does that in nanoseconds. What these hooks actually buy you is referential stability: the same object or function reference across renders, which matters when that reference feeds into a useEffect dependency array or into a child component wrapped in React.memo. Without the memoization, a new reference every render defeats the comparison in both cases — the effect re-runs, or the memoized child re-renders, regardless of whether anything meaningful actually changed.
Not a default, a targeted fix
useMemo and useCallback have real cost of their own — they allocate a slot to store the previous value and dependencies, and compare on every render. Wrapping every value and function "just in case" adds that overhead everywhere for a benefit that mostly doesn't exist. They're worth reaching for when you can point to the actual cost being avoided — a genuinely expensive computation, or a child re-render that shows up in the profiler — not as a reflex.
Composing it into a custom hook
Custom hooks aren't a separate feature — they're just functions that call other hooks, which is exactly why the rules above matter: the same call-order and dependency-array reasoning applies inside them. A debounced-value hook is a good small example because it needs both a useEffect that reacts to a changing input and a cleanup function that prevents a very real bug.
function useDebouncedValue(value, delayMs) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// Schedule an update, but only after the value has
// stopped changing for `delayMs`.
const timeoutId = setTimeout(() => {
setDebounced(value);
}, delayMs);
// Cleanup runs before the *next* effect and on unmount.
// Without it, every keystroke would leave its own timer
// running, and a stale one could overwrite a fresher value.
return () => clearTimeout(timeoutId);
}, [value, delayMs]);
return debounced;
}
// Usage: only fire the search request once the user pauses typing.
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);
return (
<input value={query} onChange={(e) => setQuery(e.target.value)} />
);
}Every keystroke changes value, which is a dependency, so the effect re-runs and schedules a new timeout. Critically, the cleanup returned from the previous run fires first and clears the previous timeout — that's what stops five rapid keystrokes from producing five competing timers, any of which could otherwise fire out of order and briefly show a stale debounced value. The hook only ever exposes the settled result, and the component using it doesn't need to know any of that plumbing happened.
Key takeaways
- Hooks are tracked by call order, not by name — calling one conditionally desyncs that order for every hook after it.
- The dependency array is compared by reference (Object.is), not by conceptual meaning — an effect runs whenever any entry's reference differs from last render.
- Stale closures come from reading a value the dependency array doesn't list; over-eager dependencies fix that at the cost of firing too often.
- Object, array, and function literals in a dependency array are a new reference every render, which defeats the comparison entirely.
- Effect cleanup runs before the next effect and on unmount — skipping it on a subscription or listener leaks it across renders.
- useMemo/useCallback buy referential stability for effects and memoized children, not raw speed — reach for them only when there's a measured cost to avoid.
