Stale Closures: Why Your State Is "Old"
Every React render captures its own props, state, and handlers in a closure — and that snapshot is exactly why your setInterval, event listener, or effect reads "old" values.
A stale closure is not a React bug — it is JavaScript closures behaving correctly in a model where your function component runs again on every render. Each render produces a fresh scope, and any callback created there captures the props and state as they were at that render. Stash such a callback somewhere that outlives the render — a setInterval, an event listener, a subscription — and it keeps reading the snapshot it was born with.
1. Every render is its own closure
The mental model that makes all of this obvious: a function component runs top to bottom on every render. The count you destructure from useState is not a live, mutable binding — it is a plain const, a number, fixed for the lifetime of that render.
function Counter() {
const [count, setCount] = useState(0);
// This `handleClick` closes over THIS render's `count`.
function handleClick() {
console.log(count); // the value from the render that created this fn
}
return <button onClick={handleClick}>{count}</button>;
}
On the render where count is 3, handleClick is a brand-new function whose count is permanently 3. React attaches the new handleClick as the click handler on every commit, so for synchronous DOM events you never notice — the latest render's closure is the one attached. The trouble starts when something holds onto an old closure.
2. The classic bug: counter in setInterval
Here is the canonical broken version. The interval is set up once and never reads a fresh count.
// BROKEN
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // `count` is captured from the first render: 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps -> effect runs once, closure frozen at count === 0
return <h1>{count}</h1>;
}
This counts 0 → 1 and then stops climbing. The effect ran once, on the first render, where count was 0. That interval callback is that render's closure forever, so every tick evaluates setCount(0 + 1), i.e. setCount(1). The first tick moves state from 0 to 1 and re-renders. After that, every tick passes 1 again while the state is already 1; React compares the next value with Object.is, sees they are equal, and bails out of re-rendering. The display freezes at 1.
The crisp interview framing: the empty deps array is not the bug, it is the symptom. The effect closed over count; an empty deps array told React "this effect never needs to re-run," so the closure is never refreshed. You have two solid ways out, plus one tempting non-fix.
3. Fix #1 — functional updates
The cleanest fix removes the dependency on the captured value entirely. The updater form of a setter receives the pending state — React's latest queued value — as its argument, so you never need to read count from the closure.
// FIXED with functional update
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // React supplies the current value
}, 1000);
return () => clearInterval(id);
}, []); // genuinely empty now: the effect reads nothing reactive
return <h1>{count}</h1>;
}
Because setCount(prev => prev + 1) reads no outer variable, the empty deps array is now correct rather than a band-aid. The interval is created once, runs forever, and React threads the real current value through prev. This is the right tool whenever the new state is a pure function of the old state — increments, toggles, accumulating into a list.
The limitation matters too: a functional update gives you the previous value of that state variable, not previous props or some other piece of state. If the interval needs to read a step prop or a different state variable, the updater form alone will not save you.
4. Fix #2 — useRef for the latest mutable value
When you need the current value of an arbitrary prop or state — not just "the previous value of the thing I'm setting" — a ref is the escape hatch. A ref is a stable container whose .current you mutate; reading it always yields the latest write, regardless of which render's closure you are in.
// FIXED with a ref mirror
function Counter({ step }) {
const [count, setCount] = useState(0);
const stepRef = useRef(step);
useEffect(() => { stepRef.current = step; }); // no deps array: runs after every commit
useEffect(() => {
const id = setInterval(() => {
// Read .current at call time, so we see the latest `step`.
setCount(prev => prev + stepRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
The key move: the stepRef object keeps the same identity across all renders, so the interval's closure — captured at mount — still points at the live container. An effect with no deps array runs after every commit and copies the newest step into stepRef.current. By the time a tick fires, the ref holds whatever step currently is.
This generalizes into a "latest callback ref," the basis of robust useInterval implementations: store the callback in a ref, update it every render, and have a single mount-time interval invoke ref.current(). The interval stays stable (no teardown thrash) while always calling fresh logic.
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay == null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]); // restart only when the cadence changes
}
5. The deps array is a correctness contract
It is tempting to "fix" the original by listing count in the deps array. It does work — but understand what it costs.
// WORKS, but tears down and recreates the interval on every change
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]); // re-run whenever count changes
Now each new count re-runs the effect: clear the old interval, create a new one with a fresh closure. It produces correct numbers, but it resets the 1-second timer every time count changes and churns intervals needlessly. For a subscription or WebSocket, this thrash is a real defect, not a style nit.
The principle the deps array encodes: list every reactive value (props, state, context values, and anything derived from them) that the effect reads. The react-hooks/exhaustive-deps lint rule enforces exactly this. When it complains, it has found a value your closure captured. The fix is almost never to silence it — remove the dependency (functional update), move the value into a ref, or genuinely re-run the effect when it changes. A lying deps array is how stale closures get shipped.
6. The same trap outside intervals
Intervals are the textbook case, but the mechanism appears anywhere a closure outlives its render:
- Manual event listeners.
addEventListener('keydown', handler)in a mount-only effect freezeshandler's closure. It reads stale state until you add deps (and re-attach) or read from a ref. - Async callbacks. A
.then()orawaitcontinuation after a slow fetch runs with the props and state of the render that launched it — which may be several renders old by the time it resolves. - Debounced/throttled functions. Memoizing a debounced function with
useMemooruseCallbackon an empty deps array captures stale values inside the debounced body. - Stale
useCallback. A memoized callback handed to a child is only as fresh as its deps array. Wrong deps means the child invokes old logic.
A note on Strict Mode: in development, React intentionally mounts a component, runs its effects, cleans them up, and runs them again. That double-invoke is a dev-only check (it does not happen in production builds) designed to surface effects with missing cleanup — an interval that is never cleared, for instance, will run twice. It does not change closure semantics; it just makes a missing clearInterval louder.
Two forward-looking notes. The React Compiler memoizes for you, but it does not change closure semantics — a value a callback reads is still the value from the render that created it. And in React Server Components, this bug does not arise on the server: a Server Component runs once per request, with no client-side render loop, no useState/useEffect, and no live closures held across re-renders. Stale closures remain a Client Component concern.
Takeaways
- Every render has its own props and state. Treat the destructured values as constants frozen at that render — because they are.
- If the next state derives only from the previous state, use
setX(prev => …). It reads nothing from the closure, so the deps array stays honestly empty. - For the latest prop or other state inside a long-lived callback, mirror it into a ref and read
.currentat call time. - The deps array is a contract, not a knob. List every reactive value the effect reads; never silence
exhaustive-depsto escape a stale closure — fix the capture instead. - Adding the value to deps is the most expensive fix: it re-runs the effect and recreates subscriptions/intervals. Reach for functional updates or refs first.
- Strict Mode's dev-only double-invoke exposes effects with missing cleanup; it is not a production behavior and does not alter closure capture.