The Stale Closure Problem
Every Render Has Its Own Closures
This is the mental model that changes everything. When React calls your component function, every variable declared during that render — including state, props, and functions — belongs to that specific render. Closures created during that render capture those values forever.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// This closure captures count from THIS render.
// If count is 0 when this function is created,
// it will always see count = 0, even if the user
// clicks 10 more times.
setTimeout(() => {
alert(`Count was: ${count}`);
}, 3000);
}
return <button onClick={handleClick}>Alert count: {count}</button>;
}
Click when count is 3, wait 3 seconds, and the alert shows "Count was: 3" — even if count is now 7. The closure captured 3, not the latest value.
Think of each render as a photograph. When you take a photo, everything in it is frozen at that moment. A closure is like a person in the photo looking at a clock on the wall — they see the time when the photo was taken, not the current time. Every render creates a new photograph with a new clock reading. Old closures look at old photographs. This is not a bug — it is how JavaScript closures work. It becomes a "problem" only when you expect to see the current time in an old photograph.
The Classic setInterval Trap
If you've used React for more than a week, you've probably hit this one. The most notorious stale closure in React:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is ALWAYS 0 in this closure
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps → effect runs once → closure captures count = 0
return <div>{count}</div>;
}
// Result: count stays at 1 forever
// setCount(0 + 1) = 1 → setCount(0 + 1) = 1 → setCount(0 + 1) = 1 ...
The effect runs once (empty deps), capturing count = 0. Every second, setCount(0 + 1) runs. Count becomes 1, but the next tick still sees count = 0. It is stuck.
Fix 1: Functional Updater
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // Reads latest state from the queue
}, 1000);
return () => clearInterval(id);
}, []);
The functional updater does not read from the closure. It receives the latest pending state from React's internal queue. This is the simplest fix for state-dependent updates in stale closures.
Fix 2: Ref Escape Hatch
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Keep ref in sync with state
});
useEffect(() => {
const id = setInterval(() => {
// ref.current always has the latest value
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
The ref is updated after every render, so countRef.current always has the latest count. The interval reads the ref instead of the stale closure value.
Fix 3: Include Dependency (Re-create Interval)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is fresh — effect re-runs when count changes
}, 1000);
return () => clearInterval(id);
}, [count]); // Re-creates interval every time count changes
This works but is inefficient ��� the interval is destroyed and recreated every second. For timers, the functional updater (Fix 1) is usually best.
Stale Closures in Event Handlers
function Chat() {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
function handleSend() {
// message is from the render when handleSend was created.
// If React batches and this handler was created during a
// previous render, message might be stale.
sendToServer(message);
setMessages(prev => [...prev, message]);
setMessage('');
}
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSend}>Send</button>
</div>
);
}
In this specific case, handleSend reads message from the current render where the click happens, so it is fresh. But if handleSend were used as a callback passed to a child component or stored somewhere, it could become stale.
Stale Closures in useEffect
function AutoSave({ data }) {
useEffect(() => {
const id = setInterval(() => {
// data is stale if deps do not include data
save(data);
}, 5000);
return () => clearInterval(id);
}, []); // BUG: data is not in deps → stale closure
// Fix: Include data in deps
useEffect(() => {
const id = setInterval(() => {
save(data);
}, 5000);
return () => clearInterval(id);
}, [data]); // Interval recreated when data changes
}
Why React chose closures over mutable refs by default
React could have designed hooks to always read the latest value (like class component this.state). Instead, each render's closures capture that render's values. Why? Predictability. In class components, this.state is mutable — if you read it in an async callback, you might get a newer value than when the callback was created. This causes subtle bugs where an effect sees "future" state. Closures give each render a consistent snapshot — all values in a render are from the same point in time. The stale closure "problem" is the cost of this consistency.
The useEvent Workaround
Until useEvent ships (and we've been waiting a while), here's the pattern for a stable callback that always reads fresh state:
function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function useStableCallback(fn) {
const fnRef = useLatest(fn);
return useCallback((...args) => fnRef.current(...args), []);
}
// Usage:
function Chat({ onSend }) {
const [message, setMessage] = useState('');
const stableSend = useStableCallback(() => {
onSend(message); // Always reads latest message
});
useEffect(() => {
// stableSend is stable — effect does not re-run when message changes
socket.on('send-shortcut', stableSend);
return () => socket.off('send-shortcut', stableSend);
}, [stableSend]);
}
Be careful with the "store latest in ref" pattern ��� updating the ref during render works for event handlers but can be problematic with concurrent features. The React team recommends updating refs in useEffect for safety. In practice, the pattern works because event handlers and effect callbacks are called after render completes.
| What developers do | What they should do |
|---|---|
| Using state value directly in setInterval: setCount(count + 1) with [] deps The closure captures count from the render when the effect ran. With [] deps, that is always the initial value. The functional updater receives the latest pending state. | Use functional updater: setCount(prev => prev + 1) |
| Expecting event handlers to see future state changes This is by design. Closures provide consistency — all values in a render are from the same point in time. Use refs for the latest value when needed. | Each render's closures see that render's values — they are consistent snapshots |
| Suppressing exhaustive-deps to avoid stale closures Suppressing the lint rule hides the stale closure bug. The linter is telling you which values your effect reads but does not react to. | Fix the closure: use functional updaters, refs, or restructure the effect |
| Storing callbacks in refs and reading during render Writing to refs during render is risky with concurrent features. The ref might be updated for a render that gets discarded. | Update refs in useEffect, read in handlers and effect callbacks |
- 1Every render creates new closures that capture that render's values — old closures never update
- 2Functional updaters (prev => next) bypass stale closures by reading from React's internal queue
- 3Refs provide an escape hatch — ref.current always holds whatever you last assigned
- 4The exhaustive-deps lint rule catches stale closure bugs — never suppress it
- 5Stale closures are the cost of consistent renders — each render is a coherent snapshot in time
Challenge: Fix the Stale Timer
Challenge: Debounce with Latest Value
// This search auto-submits after 500ms of no typing.
// But it always submits the FIRST character typed, not the latest.
// Fix the stale closure.
function Search() {
const [query, setQuery] = useState('');
useEffect(() => {
if (!query) return;
const timeoutId = setTimeout(() => {
// BUG: query is stale — captured from the first effect run
submitSearch(query);
}, 500);
return () => clearTimeout(timeoutId);
}, []); // Missing dependency!
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
Show Answer
Add query to the dependency array:
function Search() {
const [query, setQuery] = useState('');
useEffect(() => {
if (!query) return;
const timeoutId = setTimeout(() => {
submitSearch(query); // Fresh query from current render
}, 500);
return () => clearTimeout(timeoutId); // Cancel previous timeout
}, [query]); // Re-run effect when query changes
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}How it works:
- User types 'r' → effect schedules timeout with query='r'
- User types 'e' (within 500ms) → cleanup cancels 'r' timeout, new timeout for 're'
- User types 'a' → cleanup cancels 're' timeout, new timeout for 'rea'
- User stops typing → 500ms passes → submitSearch('rea') fires with the latest query
Each effect captures the current query. The cleanup cancels the previous debounce timer. The dependency array [query] ensures the effect re-runs on every keystroke.