React — useEffect Sees Stale State from Closure Capture
Diagnose React effects and callbacks reading old state because closures captured the render-time value, fix dependencies and refs, and avoid the classic timer trap.
Symptoms
- A value read inside setInterval, event handler, or async callback never updates: it stays at the initial render value
- The effect intentionally omitted a dependency to "avoid re-running", and now reads a frozen snapshot
- Console logs inside the closure show the old value while the component renders the new one
Possible causes
- The closure captured the state variable from the render it was created in, and the effect never re-ran
- setInterval or setTimeout created once with empty deps holds the first render closure forever
- A dependency array deliberately excludes a changing value to reduce runs
- An async operation resolves long after the render that created its closure
Diagnosis steps
Log both the rendered and captured values
add console.log inside and outside the closure; compareInterpretation
Rendered value fresh, closure value old confirms the capture; identical values point elsewhere.
Read the dependency array as a contract
grep -Rn -A3 "useEffect(\|useCallback(" src/ | grep -B1 "\\[\\]" | headInterpretation
An empty array holding a closure that reads changing state is the deliberate-freeze pattern that produces stale reads.
Reproduce with a timer
Test: click to increment, wait, assert the interval callback logs current valueInterpretation
The interval callback logging the initial value while the UI shows increments is the canonical reproduction.
Root cause
Every render creates fresh closures capturing that render state. An effect that does not re-run keeps using the closure from its creation render, so values it reads are frozen at that point. Omitting a dependency prevents the re-run that would refresh the capture.
Fix
Include everything the effect reads in its dependency array, use functional state updates (setCount(c => c + 1)) when only the latest value is needed, keep mutable latest values in refs for long-lived subscriptions, and clean up timers when dependencies change.
Risk note
Blanket disabling of the exhaustive-deps lint rule converts every future effect into a latent stale closure. Fix the dependencies or use refs deliberately.
Verify the fix
The timer or subscription callback reads current values across updates; tests advance state and assert the callback output matches.
Symptom walkthrough
The classic reproduction is five lines:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count is', count); // always 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps: the closure is from render #1 forever
The UI shows 1, 2, 3… while the log prints 0 forever. Every render creates a new closure over that render state — but the interval keeps executing the one created on the first render, because the empty dependency array means the effect never re-ran to install a fresher closure.
Fix in practice
Three tools by need. Functional updates when you only need the latest value:
setInterval(() => setCount((c) => c + 1), 1000); // no capture of count at all
Full dependency honesty when the effect should track the value:
useEffect(() => {
const id = setInterval(() => api.report(count), 5000);
return () => clearInterval(id);
}, [count]); // re-runs per change, always fresh closure
Refs for long-lived subscriptions that must not re-subscribe:
const latest = useRef(count);
useEffect(() => { latest.current = count; }); // every render
useEffect(() => {
const sub = ws.on('tick', () => handle(latest.current));
return () => ws.off('tick', sub);
}, []); // subscribes once, reads current via ref
Keep exhaustive-deps enabled — it is the compiler-side guard of exactly this contract.