Javascript
Closures and Stale State in Handlers
Reading level
The counter that always says 1
You build a click counter. You click the button 5 times quickly. The count says 1. You add a console.log — it prints the right number inside the handler. But the state update only shows the last value you started with. What's happening?
You've hit a stale closure. Your event handler captured an old version
of count when it was created — and every click is updating from that
same old starting point instead of the latest value.
Stale closures appear in React hooks, setInterval callbacks, event listeners added once but reading dynamic state, and async functions that run after state has already changed. The root is always the same: the function captured a variable at definition time, not at call time.
The closure is working correctly — it's a design decision that closures capture bindings
at creation time, not lazily. The bug is in assuming that a closure over a let
binding in a React component's render scope stays fresh across renders. It doesn't.
Each render creates a new scope; old closures hold the old scope's values.
What a closure actually captures
A closure is a function that remembers the variables from where it was created. Think of it as a function carrying a backpack of variables.
function makeGreeter(name) {
return function greet() {
console.log('Hello, ' + name); // captured 'name'
};
}
const greetAlice = makeGreeter('Alice');
greetAlice(); // "Hello, Alice" — always, even if nothing called 'Alice' exists anymore
The backpack contains a snapshot of name at the moment makeGreeter
ran. That's fine for a greeter. The problem comes when the backpack should have today's
value, but it still holds yesterday's.
In React, each render is a function call. useState returns the current value
for this render. An event handler created in this render closes over this render's values.
If that handler is called in a future render (e.g., from a setTimeout, setInterval, or
an unremoved event listener), it reads the past render's values.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // ❌ captures count from first render — always 0 + 1 = 1
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — only runs once, captures count = 0 forever
}
The eslint-plugin-react-hooks exhaustive-deps rule exists specifically to surface this pattern.
When you suppress it with // eslint-disable, you are choosing to accept a stale
closure — sometimes intentionally (stable refs), sometimes by mistake.
The two canonical solutions operate at different layers: functional updates break the closure dependency entirely; refs provide a mutable container that closures can always read fresh.
Three stale closure traps
Trap 1: setInterval with empty deps
// ❌ count is always 0 inside the interval
useEffect(() => {
setInterval(() => setCount(count + 1), 1000);
}, []); // captured count = 0, never updates
Trap 2: async handler reading stale state
// ❌ Clicks during the fetch read the count from when the handler was created
async function handleClick() {
await fetch('/api/save');
console.log(count); // stale — not the count at save time
}
Trap 3: addEventListener without cleanup
// ❌ Each render adds a new listener; old ones hold old closures
useEffect(() => {
window.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit(formData); // stale formData
});
}); // no cleanup, no deps
Each trap shares the same root: a function created in one render scope is called in a context where state has moved forward. The function's backpack still holds the old state.
The eslint exhaustive-deps warning on trap 1 is: React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. Adding count to deps "fixes" the lint warning but creates a new interval on every count change — a different bug.
Trap 3 is common in legacy code migrated from class components, where this.state
was always read fresh. Function components don't have this — each render's
function scope is its own frame on the call stack, not a shared mutable object.
Three clean fixes
Fix 1: functional state update (breaks the closure dependency)
// ✅ The updater function receives the latest state as an argument
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // prev is always current — no closure on count
}, 1000);
return () => clearInterval(id);
}, []); // safe with empty deps now
Fix 2: useRef for latest value access
// ✅ Ref is mutable — closures always see the latest .current
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
async function handleClick() {
await fetch('/api/save');
console.log(countRef.current); // fresh — always the latest count
}
Fix 3: proper cleanup with correct deps
// ✅ Add dep + cleanup — handler recreated when formData changes
useEffect(() => {
const handler = (e) => {
if (e.key === 'Enter') submit(formData); // fresh formData
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [formData]); // re-run when formData changes
Rule of thumb for choosing:
- If you only need to update state based on previous state → functional update
- If you need to read state inside an async or long-lived callback → ref
- If the dependency is an input or callback prop → exhaustive deps + cleanup
A fourth pattern — useEffectEvent (React 19 / experimental) — creates
an event handler that always reads the latest state without being a dependency. It
formalises the "ref to latest callback" pattern into a first-class primitive. Watch
for it landing in stable React.
Pattern at a glance
Annotated example: setInterval in useEffect — stale closure vs correct deps
❌ STALE CLOSURE
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps — count frozen at 0
Empty dep array: count is always 0 in the closure
✅ FUNCTIONAL UPDATE
useEffect(() => {
const id = setInterval(() => {
setCount(prev => {
console.log(prev); // always latest
return prev + 1;
});
}, 1000);
return () => clearInterval(id);
}, []); // safe — no dep on count
Functional update: closure always captures current value via prev
Try it: stale vs fresh counter
Click "Broken" and rapidly click the +1 button many times — you'll see the stale closure effect. Switch to "Fixed" and the same fast clicking works correctly.
The broken version uses setCount(count + 1); the fixed version uses setCount(prev => prev + 1). That one word — "prev" — breaks the closure dependency.
Under the hood: React batches state updates in event handlers. The broken version reads the same count value for every batched call. The functional updater queues correctly regardless of batching.
Showing: Fixed — functional update
React's exhaustive-deps rule is your best friend
Install eslint-plugin-react-hooks (it ships with Create React App and Vite
React templates). It warns you about missing dependencies. Don't suppress the warnings
— they're almost always pointing at a real bug.
The exhaustive-deps rule tells you when your closure captures a value it didn't declare as a dependency. Three valid responses:
- Add the dep (re-run effect when it changes)
- Use a functional updater (remove the dep need entirely)
- Use a ref (mutable escape hatch for stable callbacks)
Suppressing with // eslint-disable-next-line is almost always wrong.
useEffectEvent (RFC #220, available behind experimental_useEffectEvent
in React 18.3) resolves the long-standing tension between exhaustive deps and stable
callback identity. It wraps a function so it always reads fresh state while remaining
stable as a reference:
// React 19 (experimental_useEffectEvent in 18.3)
import { experimental_useEffectEvent as useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // reads latest theme, stable identity
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected); // safe — onConnected is stable
return () => connection.disconnect();
}, [roomId]); // theme NOT in deps, but always reads latest
}
References
Remember
Key takeaways
-
A closure captures the value of a variable when the function is created — not when it's called.Each React render creates a new scope; closures over state from one render don't auto-update when state changes in a later render.The stale closure pattern is specifically: function created in render N holds render N's state, called in render N+M — always reading N.
-
Use
setCount(prev => prev + 1)instead ofsetCount(count + 1)when updating state in async or interval callbacks.Functional state updates break the closure dependency — the updater receives the latest state as its argument regardless of when it runs.useRef is the stable mutable container for closures that need to read (not update) latest state — it survives re-renders without triggering them. -
The exhaustive-deps ESLint rule is almost always right — don't suppress it without understanding why.Three valid responses to a missing-deps warning: add the dep, use functional update, or use a ref. Suppressing is rarely the right answer.useEffectEvent (React 19) formalises the "ref to latest callback" pattern — watch for it stabilising as the canonical solution to this class of bug.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed