When updates don’t compose: stale closures + batching
Async callbacks capture render-time snapshots — not live state. When updates depend on previous state, non-composable computations can collapse under batching — breaking temporal correctness.
Break it: stale closure in async
Click fast a few times. You expect +3, but identical updates collapse into +1, because every timeout uses the same captured value.
Repro steps
- Click “+1 async” three times quickly.
- Wait for the timeouts to land.
- Observe the total: it often ends up +1 instead of +3.
Detected issue
Status: READY / No drift detected yet. Multiple timeouts compute count + 1 from the same snapshot captured in the render closure.// ❌ Buggy: reads from render closure
setTimeout(() => {
setCount(count + 1);
}, 250);Why this breaks
Mental model: render snapshots + closures
State inside a render is a snapshot. Closures capture snapshots, not live state. This is not a framework bug — it’s a temporal correctness bug.
- A stale closure happens when an async callback (like
setTimeout) captures state from the render it was created in. - If you schedule multiple async updates quickly, they can all reference the same captured value.
- Modern UI frameworks batch updates. When computations are identical, they collapse.
Invariant that breaks
Invariant: State updates must compose correctly regardless of time, scheduling, or batching.Reference (correct mental model)
Correct mental model
Incorrect / Buggy
- Reading
countfrom the render closure in async callbacks. - Assuming two
setCount(count + 1)calls add +2. - Assuming updates always apply sequentially to “the latest value”.
Batching pitfall (React 18)
In React 18, multiple updates in the same tick are often batched. If you write setCount(count + 1) twice, you don’t get +2 — you typically get +1.
Repro steps (batching)
- Click “+1 sync (x2)” once.
- Observe that the total increases by +1 (not +2).
// React 18 batching: these often collapse into +1
setCount(count + 1);
setCount(count + 1);
// ✅ Fix
//Functional updates compose. Render closures don’t.
setCount(prev => prev + 1);
setCount(prev => prev + 1);Correct
- Async callbacks read values from the render they were created in.
- If the next state depends on the previous one, express it as a function.
- Functional updates compose correctly under batching and async.
Fix: functional updates
If the next state depends on the previous state, use setState(prev => ...).
Detected issue
Status: OK. Functional updates compute from the real previous state, so async callbacks and batching compose correctly.// ✅ Fixed: functional update composes correctly
setTimeout(() => {
setCount(prev => prev + 1);
}, 250);Rule of thumb
If the next state depends on the previous one, never read from a render closure. Use functional updates. Always use a functional update.Key takeaways
- Async callbacks read values from the render they were created in (Closures capture snapshots — not live state).
- If the next state depends on previous state, always use functional updates.
- Batching collapses non-composable updates into a single result — exposing bugs that only appear under real scheduling.
- This is not a framework bug — it’s a temporal correctness bug.
Approach & tradeoffs
Approach
- Treat render-time state as an immutable snapshot in time; async code must not assume 'latest'.
- Use functional updates for any increment/append/merge that depends on previous state.
- Keep updates composable so concurrency and batching don’t change correctness.
Tradeoffs
- Functional updates can be slightly more verbose, but they’re correctness-first.
- If you need derived values, compute them from the functional 'prev' path or inside reducer/actions.
Common pitfall
Slow interactions hide temporal bugs. Concurrency exposes them.