Explain stale closure bugs in React through timers, async handlers, subscriptions, and effects that read an old render snapshot instead of the latest state.
Use this React interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Why does React sometimes show stale state in closures? How do you fix it?Frontend interview answer
This React interview question tests whether you can explain React stale closures: async bugs, latest-state fixes, and effect debugging, connect it to production trade-offs, and handle common follow-up questions.
- React stale closures: async bugs, latest-state fixes, and effect debugging explanation without falling back to memorized docs wording
- Hooks and Closure reasoning, edge cases, and production failure modes
- How you would answer the most likely React interview follow-up
Common production bug
Stale state in React closures is really a render-snapshot bug: each render creates new closures, and async code may keep reading an older one. That is why intervals, delayed handlers, and effects sometimes log or submit outdated values. The fix is not “React is wrong”; it is understanding when to use functional updates, refs, or correct dependencies.
The mental model
React components are not long-lived objects. They are more like:
"Functions that React calls again and again to produce UI."
Every render creates a new snapshot of props, state, and functions. Closures created in that render freeze those values.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // ❌ Might log stale value
}, 1000);
return () => clearInterval(id);
}, []); // empty deps
return <button onClick={() => setCount(c => c + 1)}>+</button>;
}
Why this logs stale state
This effect runs only once. The closure inside setInterval captures count from the first render (which is 0).
Even if state updates later, this callback still sees count = 0 forever. Not a React bug. Just JavaScript closures + React render model.
Cause | What’s actually happening | Symptom |
|---|---|---|
Closure captures old render values | Callback keeps references to old variables | State appears "stuck" or outdated |
Effect deps are missing | Effect never re-runs with new state | Logic uses old props/state |
Async logic (timeout, interval, promise) | Runs after render that created it | Reads stale snapshot |
Fix #1: Add correct dependencies
Tell React to recreate the closure when state changes:
useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, [count]); // ✅ now closure updates when count changes
Fix #2: Use functional updates (most common fix)
If you are updating state based on previous state, don’t read from the closure at all:
setCount(c => c + 1); // ✅ always uses latest state
Fix #3: Use useRef for mutable latest value
If you need to read the latest value from long-lived async callbacks:
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // ✅ always latest
}, 1000);
return () => clearInterval(id);
}, []);
Worked async example: delayed submit + permission flow
Timers are only one stale-closure variant. The same bug appears when a submit handler starts async work and later reads a value that has changed since the click.
// ❌ Async callback keeps the snapshot from the click that started it
function Checkout({ canSubmit }) {
const [status, setStatus] = useState('idle');
async function handleSubmit() {
setStatus('checking');
await verifyPaymentIntent();
if (!canSubmit) {
setStatus('blocked');
return;
}
setStatus('submitted');
}
return <button onClick={handleSubmit}>Pay</button>;
}
// ✅ Keep the latest permission in a ref for long-lived async work
function CheckoutFixed({ canSubmit }) {
const latestCanSubmit = useRef(canSubmit);
const [status, setStatus] = useState('idle');
useEffect(() => {
latestCanSubmit.current = canSubmit;
}, [canSubmit]);
async function handleSubmit() {
setStatus('checking');
await verifyPaymentIntent();
if (!latestCanSubmit.current) {
setStatus('blocked');
return;
}
setStatus('submitted');
}
return <button onClick={handleSubmit}>Pay</button>;
}
Important insight
This is not a React quirk. This is how JavaScript closures work combined with React’s "each render is a snapshot" model.
When stale closures are expected
A stale closure is not automatically a bug. Sometimes you intentionally want the snapshot from the click that started the work, such as logging which filters were active when an export began. The real question is: should this callback use the latest value, or the value from the render that created it?
Interview framing
Say it like this:
"Each render creates a new closure with its own state snapshot. Async callbacks keep the snapshot from the render they were created in. Stale state happens when effects or callbacks don’t re-run with new dependencies. You fix it with correct deps, functional updates, or refs."
Stale state happens because closures capture values from a specific render. React re-renders create new snapshots, but old async callbacks keep old ones. Fix it by using correct dependencies, functional state updates, or a ref for reading the latest value.
Use this as one explanation rep, then continue with the React interview questions cluster or a guided prep path.