I5 · Part 2 · Intermediate
Effects in depth
Stale closures, the dependency array as a contract, and the effects that should not exist.
09On this page
The stale closure
The single most common effect bug, and once you see the mechanism you can spot it anywhere.
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is frozen at 0, forever
}, 1000);
return () => clearInterval(id);
}, []); // runs once — captures the first render
The effect ran during the first render, so the arrow inside it closed over
that render's count — zero. It never re-runs, so it
never sees another one. The counter goes 0, 1, 1, 1, 1.
Four fixes, in order of preference:
// 1. don't read it at all — the updater form gets the latest value
setCount((c) => c + 1);
// 2. depend on it, and accept the interval restarting
}, [count]);
// 3. useEffectEvent — read the latest value without depending on it
const onTick = useEffectEvent(() => setCount(count + increment));
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, []); // no dependencies, no stale values
// 4. the ref workaround this replaces — still everywhere in existing code
const latest = useRef(count);
useEffect(() => { latest.current = count; }); // keep it current after every render
useEffect(() => {
const id = setInterval(() => {
setCount(latest.current + 1); // the interval reads the ref, not the closure
}, 1000);
return () => clearInterval(id);
}, []); // still no dependencies, and no stale value
useEffectEvent, properly
Stable since React 19.2, and it is the sanctioned answer to the problem the ref workaround was invented for. An Effect Event is a function that always sees the latest props and state, but has a stable identity — so an effect can call it without listing it as a dependency.
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showToast("Connected!", theme); // always the current theme
});
useEffect(() => {
const conn = connect(roomId);
conn.on("connected", onConnected);
return () => conn.disconnect();
}, [roomId]); // theme is NOT a dependency
}
Without it, adding theme to the dependencies reconnects the chat
every time the user switches to dark mode; leaving it out shows the toast in
the wrong theme. Neither is right, and that is the gap the hook closes.
The line it draws
An effect's dependencies should be the things it synchronises with. Values it merely reads when something happens are not synchronisation — they are event logic that happens to live inside an effect. That is the distinction the hook makes explicit, and it is why the name is "Effect Event" rather than "latest ref".
Two constraints. An Effect Event can only be called from inside an effect in the same component — never passed to a child, never called during render. And it is not a general escape from the dependency array: if you find yourself wrapping the whole effect body in one, the effect is synchronising with nothing and should be an event handler instead.
The dependency array is a contract, not a lint rule
It says: this effect stays in sync with these values. Removing a dependency to stop an effect re-running does not fix anything — it freezes the effect on an old value and turns a visible loop into a silent wrong answer.
If satisfying the linter breaks the effect
The effect is modelling the wrong thing. Something in it is not synchronisation — it is a reaction to an event, or a derived value, or an initialisation that should happen once elsewhere. Fix the shape rather than silencing the rule.
Removing dependencies honestly
// object dependency → depend on the field
}, [user]); → }, [user.id]);
// function dependency → move it inside the effect
useEffect(() => {
function connect() { ... } // now nothing external to depend on
connect();
}, [roomId]);
// or hoist it out of the component entirely
function makeUrl(id) { ... } // module scope: never changes
Each of these removes a dependency by making it genuinely unnecessary, rather than by lying about it.
One effect per concern
// ✗ two unrelated jobs, one dependency list
useEffect(() => {
document.title = title;
const id = setInterval(poll, 5000);
return () => clearInterval(id);
}, [title]); // the interval restarts when the title changes
// ✓
useEffect(() => { document.title = title; }, [title]);
useEffect(() => {
const id = setInterval(poll, 5000);
return () => clearInterval(id);
}, []);
Effects are cheap. Splitting them is almost always right, because each one then has the dependency list it actually needs.
The taxonomy: what is really an effect
| You want to… | Use |
|---|---|
| Compute something from props or state | Nothing — calculate it during render |
| React to a click, submit or keypress | The event handler |
| Reset state when a prop changes | A key on the component |
| Adjust some state when a prop changes | Set it during render, guarded by a comparison |
| Cache an expensive calculation | useMemo |
| Subscribe to an external store | useSyncExternalStore |
| Fetch data | A data library, or the framework's loader |
| Connect to a socket, a timer, a browser API | an effect |
| Measure the DOM and adjust before paint | useLayoutEffect |
Two rows on that list are effects. That ratio is roughly right for real codebases too.
Adjusting state during render
The unusual one from that table. When some state must change because a prop changed, an effect renders once with the wrong value first. React supports setting state during render instead:
function List({ items }) {
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) { // during render, guarded
setPrevItems(items);
setSelection(null);
}
...
}
React sees the state change before it commits, discards the in-progress output and re-runs the component immediately — nothing is painted with the stale value. The guard is essential; without it this is an infinite loop.
Prefer a key when you want to reset everything. This is
for resetting one field while keeping the rest.
Race conditions, properly
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err); // ignore our own cancel
});
return () => controller.abort();
}, [url]);
AbortController beats the cancelled flag because it
also stops the request in flight, freeing the connection. Both fix the same
bug: an older response arriving after a newer one and overwriting it.
Effects and Strict Mode
In development React mounts, unmounts and remounts every component once. An effect that is not idempotent shows up immediately: two websocket connections, two subscriptions, a doubled analytics event. The fix is always the cleanup, and the same bug would appear in production the first time a user navigated away and back.
Interview answer, one sentence
"Most effect bugs are one of two things: a stale closure, because the effect captured a render it never re-ran for, or a dependency someone deleted to stop it looping. The array is a statement about what the effect stays in sync with — and the deeper skill is recognising that derived values, event reactions and prop-driven resets are not effects at all. The React Compiler does not change this: it memoises values, but it will not tell you an effect was unnecessary, and its lint rules flag setting state in an effect for exactly that reason."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series