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
  1. Click “+1 async” three times quickly.
  2. Wait for the timeouts to land.
  3. Observe the total: it often ends up +1 instead of +3.
Buggy (async)
Three fast clicks schedule three timeouts — but each uses the same captured value.
Total: 0
Pending timeouts: 0
Status: READY
Detected issue
Status: READY / No drift detected yet. Multiple timeouts compute count + 1 from the same snapshot captured in the render closure.
Expected after clicks: 0 · Actual: 0

Buggy snippet

// ❌ 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 count from 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)
  1. Click “+1 sync (x2)” once.
  2. Observe that the total increases by +1 (not +2).
Batching (React 18)
Two updates in the same tick often collapse into the same computation.
Total: 0
Expectation: buggy often +1, fixed +2

Batching pitfall

// 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 => ...).

Fixed (async)
Each timeout increments from the real previous state.
Total: 0
Pending timeouts: 0
Status: OK
Detected issue
Status: OK. Functional updates compute from the real previous state, so async callbacks and batching compose correctly.

Fixed snippet

// ✅ 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.