Stale Closure in React
Prompt
A teammate filed a bug: "the live counter on the dashboard logs the same number
forever even though the on-screen count goes up." This Timer component is the
suspect. Review it and the bug together — form a hypothesis about the root cause
before you reach for a fix.
function Timer() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
How this round runs
State your hypothesis for why the logged value is stuck before changing any code, then separate the root cause from the symptom. Tell me what the fix is and what trade-off it introduces.
Model answer
Hypothesis, stated before touching code: this is a stale closure over count,
and the giveaway is that the on-screen Count updates (so state and re-render
are fine) while only the logged value is stuck. That split points squarely at
the interval callback, not at the render.
Why it happens (root cause, not symptom): the effect has an empty dependency
array, so it runs exactly once, on mount. The setInterval callback created then
closes over the count binding from that first render — value 0. Clicking
Increment updates state and re-renders, but because the dependency array is [],
the effect never re-runs, the old interval keeps ticking, and its callback keeps
reading the count it captured: 0, forever. The symptom is "logs 0"; the root
cause is "the effect captured a stale binding and was told never to refresh it."
I'd confirm the hypothesis cheaply before committing: log count in render too —
it will climb 0,1,2… while the interval still prints 0, proving the divergence is
in the captured closure, not in state. The empty [] is the smoking gun.
Two fixes, with the trade-off named:
React.useEffect(() => {
const interval = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(interval);
}, [count]);
Adding count to the deps re-runs the effect on every change so the closure is
always current — but it tears down and recreates the interval on every tick of
the counter, which resets the timer's phase and is wasteful if count changes
often. If you want a single stable interval, read the latest value through the
functional updater instead:
React.useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => {
console.log(prev);
return prev;
});
}, 1000);
return () => clearInterval(interval);
}, []);
setCount((prev) => …) always receives the current state regardless of what the
closure captured, so one interval lives for the component's lifetime. (A useRef
mirroring count is the third option when you need the value but not a state
update.)
What I ruled out: it's not a state-update bug (the visible count increments), not
a re-render bug (the <p> reflects new values), and not a setState-batching
issue. It's specifically the captured binding plus the [] that pins it.
- Stated the stale-closure hypothesis before editing, and justified it from the symptom (screen updates, log doesn't)
- Separated root cause (effect captured the count binding + [] never refreshes it) from symptom (logs 0)
- Proposed a cheap confirmation (log count in render to show the divergence) instead of guessing
- Gave both fixes and named the trade-off: [count] recreates the interval each tick vs the functional updater keeping one stable interval
- Explicitly ruled out state/render/batching as the cause
- Why doesn't the on-screen count have the same problem? → JSX reads count fresh on each render; the interval callback is frozen from mount.
- What does the lint rule react-hooks/exhaustive-deps say here? → It flags the missing count dep — that warning is exactly this bug.
- When would you reach for useRef instead? → When you need the latest value inside the interval without triggering a state update or recreating the timer.