Stale closures in React
A useEffect with an empty dependency array logs the count from the first render, forever, even as the number on screen climbs. Nothing about closures is broken here — this lesson traces exactly which variable got captured and when, then works through the two real fixes, recreating the closure on a dependency change versus reading a ref that's always current.
Stale closures in React
You build a counter. A button increments it, the number on screen updates correctly, and somewhere you add a setInterval to log the count once a second, just to watch it climb. Except it doesn't climb — the console prints 0, 0, 0, forever, no matter how many times you click. The number on the page is right. The number in the console is stuck at whatever it was the instant the page loaded.
There's no bug in setInterval, no bug in useState, no bug in closures. Every piece is working exactly as documented. The mismatch is between what you assumed a closure captures and what it actually captures — and once that distinction is precise, the "stuck" console log stops looking like a mystery and starts looking like the only thing that could possibly have happened.
This lesson pins down what a closure captures, walks through the classic stale-interval example line by line, and covers both real fixes — recreating the closure on a dependency change, and reading a ref instead of a captured variable — with the actual trade-off between them, not just "use this one."
What a closure actually captures
Say it precisely, because the imprecise version is exactly what leads people astray: a closure captures variable bindings from the scope it was defined in, not a live view that magically stays in sync with "whatever's current," and not a frozen snapshot of values copied out at creation time either. A binding is a name pointing at a storage location. When the function created by the closure eventually runs — which might be immediately, or might be a second later, or an hour later — it looks up those names in the scope where they were defined and gets whatever is sitting in that storage location right now, at call time, not at creation time.
For an ordinary mutable variable, that's why closures can look "live": if the outer variable gets reassigned before the closure runs, the closure sees the new value, because it never held a value at all — it held a reference to the binding. Try this outside of React entirely:
let x = 1;
function readX() {
console.log(x);
}
x = 2;
readX(); // logs 2 — readX looked up the current value of x when it ranreadX doesn't capture "2" and it doesn't capture "1." It captures the variable x, and reads whatever x resolves to at the moment it's called. That's the entire mechanism, and it holds in React exactly as much as it holds anywhere else in JavaScript. The part that's different in React isn't the closure semantics — it's what "the outer variable" even refers to.
Why React breaks the intuition: every render is a new scope
A React component is a function, and like any function, calling it again creates an entirely fresh set of local variables — new bindings, not updated versions of the old ones. useState doesn't hand a component a single mutable box it can silently reassign; every render gets its own count binding, holding whatever value React decided that render's state was, and that binding is never touched again after that render finishes. The next render doesn't update the old count — it creates a brand new one, with a new value, coexisting in memory with the old one (which nothing references anymore, so it just gets garbage collected once nothing needs it).
That's the piece that makes stale closures a React-specific gotcha even though closures themselves aren't doing anything unusual: any function you define inside a component body — including the callback you hand to setInterval, setTimeout, an event listener, or a useEffect — closes over that render's count binding specifically. It has no way to see a later render's count, because that's a different binding entirely, created by a call to the component function that hadn't even happened yet when your callback was defined.
The broken example, in full
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []); // empty deps: effect runs once, on mount
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}Trace it exactly. On mount, Counter runs for the first time, and count is bound to 0 for this render. The effect's callback runs (because [] means "only after the first render"), and inside it, setInterval is handed an arrow function — created right then, during this one execution of the effect — that references count. That arrow function closes over the mount render's count binding, which holds 0 and will never hold anything else, because a render's bindings are never mutated after the fact.
Click the button a few times. Each click calls setCount, React re-renders Counter, and each of those re-renders creates a new count binding — 1, then 2, then 3 — visible on screen because the JSX for that render reads the current render's count. But the effect's dependency array is [], so the effect itself never re-runs: no cleanup fires, the original setInterval from mount is never cleared, and no new interval callback is ever created. The exact same function object, created once at mount, keeps firing once a second, and it still closes over the one binding it ever had access to — the mount render's count, permanently 0. It's not that the closure "went stale" over time. It was always only ever going to see 0; there was never a mechanism by which it could see anything else.
Fix 1: put count in the dependency array
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]); // now depends on count
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}Be precise about what this changes, because "it fixes it" isn't a mechanism. The old closure isn't repaired — a closure's captured bindings can't be edited after the fact, from outside or in. What happens instead is that React now compares count across renders, and whenever it differs from the previous render's value, React runs this effect's cleanup function first — calling clearInterval on the interval that closure was driving — and then runs the effect body again from scratch. That second run creates a new arrow function, passed to a new setInterval call, and that new function closes over this render's count binding, whatever it currently is. Every time you click, the cycle repeats: old interval torn down, old closure discarded (and eventually garbage collected, since nothing references it anymore), new interval created with a closure over the fresh value.
So the fix is really "stop reusing a stale closure by throwing it away and making a new, correctly-scoped one every time the value it needs changes" — not "make closures dynamic," which was never on the table.
Fix 2: read the current value through a ref instead
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
}, 1000);
return () => clearInterval(id);
}, []); // stays empty — this effect never needs to rerun
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}This sidesteps the whole problem instead of solving it the way Fix 1 does. countRef is created once, on mount, and React returns that exact same object on every subsequent render — its identity never changes, only the value sitting inside its .current property does, and a separate effect keeps that property synced to the latest count on every render where it changed. The setInterval callback still closes over a variable from its defining scope, exactly as closures always do — but the variable it closes over is countRef, the stable object, not count, the per-render number. Because .current is read at the moment the interval fires, not at the moment the callback was created, it always reflects whatever the most recent render wrote there, no matter how long ago the callback itself was defined.
This is the same "ref is memory, not a signal" idea from Refs vs. state: why the hot path skips setState, applied one level deeper: the closure captures the ref object, and the ref object's identity is stable, so re-reading .current inside an old closure is not the same thing as re-reading a stale variable — it's reading a live property of an object you've had access to the whole time.
Notice what didn't have to happen: no cleanup, no new setInterval, no new closure ever created after mount. The effect's dependency array stays [], honestly this time, because nothing inside that effect needs to change when count changes — only the ref's contents need to change, and mutating a ref's .current doesn't require tearing anything down.
Put the two fixes side by side and the difference in mechanism is exactly what's driving the difference in behavior: Fix 1 keeps making a fresh closure that's correct-until-the-next-change; Fix 2 makes one closure, once, that stays correct forever because what it's reading is a mutable box, not a frozen number.
The actual trade-off
Fix 1 is the more idiomatic choice and the one to reach for by default — it keeps the effect's dependencies honest (everything the effect body reads is declared as a dependency, which is what the exhaustive-deps lint rule is checking for) and it doesn't introduce a second, easily-desynced piece of state alongside count. Its cost is that the underlying resource — here, the interval — gets torn down and recreated on every single change to the dependency. For a console.log, that churn is invisible. For something like a setInterval whose timing matters — you specifically want a steady one-second cadence that doesn't reset its clock every time unrelated state changes — recreating it on every dependency change is exactly the bug you're trying to avoid, just moved one level up.
Fix 2 avoids that churn completely: the interval is created once and never touched again, so its timing is genuinely stable regardless of how often count changes. What it costs is a second source of truth that isn't automatically kept honest by React — countRef only stays accurate because a second effect remembers to sync it on every render, and forgetting that sync (or syncing the wrong ref) is a class of bug Fix 1 doesn't have, because Fix 1 has no ref to forget. Reach for it specifically when the thing you don't want to recreate — a timer, a WebSocket, a long-lived subscription — has behavior of its own that a teardown-and-restart cycle would disrupt, not as a default replacement for dependency arrays.
The dependency array is the mechanism doing the real work in Fix 1, and it deserves a lesson of its own for the cases beyond this one — when it should be empty, when adding a value to it is right versus a sign the effect is structured wrong in the first place. That's useEffect discipline: when it's the right tool.
Go deeper
- Kent C. Dodds — How React Uses Closures to Avoid Bugs — Reframes the 'stale closure' problem as closures actually working correctly and consistently — the bug is in what got captured, not in how closures behave.
- MDN — Closures — The precise language-level definition of what a closure captures and when, underneath all of React's specific vocabulary for the same idea.
- React docs — Referencing Values with Refs — The official basis for Fix 2 — why a ref gives you a value that's always current at read-time, with no new closure required.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Precisely, what does a JavaScript closure capture — the current value of a variable, a live link to 'whatever the outer scope currently is,' or something else? State it in a way that explains both ordinary JS closures and the React example in this lesson.
- In the broken example, why does the interval's console.log stay stuck at the mount-render's count instead of eventually catching up to later values?
- A component re-renders five times after mount. How many distinct `count` bindings have existed in total, and what happens to the ones from earlier renders?
- Walk through what happens, mechanically, the moment `count` changes in the Fix 1 version: what does React do to the old effect before running the new one, and what gets created fresh as a result?
- In Fix 2, the setInterval callback still closes over a variable. Which variable, and why does reading it never go stale the way reading `count` directly did?
- You have an interval whose exact one-second cadence matters and must not reset when unrelated state changes elsewhere in the component. Which fix should you use, and what specifically would go wrong with the other one?
- Someone claims Fix 1 'refreshes' the old stale closure so it can see the new count. Explain why that's not what happens, and describe what actually happens to the old closure instead.