Lesson 25 of 31
Stale Closures and Common Bugs
The general shape of the stale-closure bug beyond useEffect's dependency array — setInterval capturing an old value, listeners that never see updated state, and the two reliable fixes.
You've met one specific version of this bug already, in the dependency array lesson: an effect that reads a prop but omits it from the array, keeping a permanently outdated snapshot. This lesson generalizes it, because the same underlying mechanism — a stale closure — causes several of the most confusing bugs in real React code, not just that one.
The mechanism, in plain JavaScript
A closure is just a function that remembers the variables from the scope it was created in. React components re-render by calling the component function again, which means every render creates entirely new closures — new copies of any inner function, each one remembering that render's values of props and state. A stale closure bug happens whenever one of those old closures is kept around and called after a later render has already moved on to new values, and the old closure has no way of knowing that.
The classic interval example
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // reads `count` from THIS render, forever
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — this effect (and its closure) only runs once
return <p>{count}</p>;
}Because the dependency array is [], this effect runs exactly once, on the
first render — where count is 0. The arrow function passed to
setInterval closes over that specific 0, and since the effect never
runs again, that closure is never recreated with a fresher count. Every
second, it computes 0 + 1, sets state to 1... and the component
re-renders with a new count of 1 — but the interval callback itself is
still the old one from the first render, still computing 0 + 1. The
display gets stuck at 1 forever, ticking uselessly in the background.
Fix one: functional updates
The cleanest fix, when the update only needs the previous value of the same state being changed, is the functional update form from the useState lesson:
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // reads the TRUE latest state when applied
}, 1000);
return () => clearInterval(id);
}, []);This sidesteps the stale closure entirely — it no longer matters what
count the closure captured, because React calls this function with
whatever the actual current state is at the moment the update is applied,
not with a value read from a stale variable.
Fix two: correct the dependency array
Functional updates only help because the new value is derived from the old value of the same state. When a closure needs some other value — a different prop, a different piece of state — the general fix is the dependency array itself: list what the effect actually reads, so its closures get rebuilt with current values whenever those values change.
function LiveSearch({ query }) {
useEffect(() => {
const id = setTimeout(() => {
runSearch(query); // stale if query isn't a dependency
}, 300);
return () => clearTimeout(id);
}, [query]); // correct — a fresh closure (and timeout) per query
}Here, each time query changes, cleanup cancels the previous timeout
(from the earlier closure, with the earlier query) and a new effect run
creates a fresh closure that correctly captures the current query.
The same bug, outside useEffect
Because closures are a general JavaScript mechanism, not something specific
to useEffect, the same failure shape can appear anywhere a function from
an earlier render is retained and called later without being refreshed —
inside a useCallback with an incomplete dependency array, for instance, or
a ref holding onto an old callback. The lesson generalizes: whenever
something behaves as though it's "not seeing the latest state," the first
thing worth checking is whether a closure somewhere is holding onto an old
render's snapshot instead of being recreated with the current one.
Try it yourself
The buggy version is shown commented out; the working version uses the functional update form. Watch the count actually keep incrementing, instead of freezing at 1:
What to remember
- A stale closure happens when a function from an old render is retained and called after a later render has moved on, still referring to that old render's captured values.
- The dependency-array version (an effect missing a value it reads) is one specific case of a more general JavaScript closure behavior.
- The functional update form (
setState(x => ...)) fixes the common case of a closure needing the latest value of the same state it's updating. - For anything else a closure reads, the fix is making sure the enclosing effect/callback's dependency array actually lists it, so the closure gets rebuilt with current values.
Check yourself
4 questions · pass 3/4 to unlock Error Boundaries
1.In
useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, []), why does count only ever go from 0 to 1 and then stop increasing, even though the interval keeps firing?2.What's one general fix for a stale closure that reads state to compute a new value based on the old one?
3.What's the other general fix, when a stale closure needs to read something other than the state being updated (e.g. a separate prop)?
4.Why can a stale closure also happen in a plain event handler passed as a prop, not just inside useEffect?
4 left to answer