Stale state in asynchronous callbacks

Preventing stale state in React using functional updates

January 15, 2026

The code is superficially correct and will often pass initial review. it compiles and renders, and the first interaction behaves as expected. However, it contains a subtle closure bug that surfaces only after the callback is invoked multiple times. A socket emits a "message" event. Each time it fires, we increment an unread counter. The first event works, but the second does not. No error. No warning. Just stale state.

The issue

When the callback is created, it captures the value of unread from this render. That value does not magically update later. So if the callback was created when unread === 0, every future message event effectively runs:

setUnread(0 + 1);

Why this happens

Key points to keep in mind:

  • - React state variables are immutable per render
  • - Callbacks close over variables, not future updates
  • - Event handlers can outlive many renders

During a render, React produces a snapshot of state and props. Any functions created during that render close over that snapshot. When those functions are handed to external systems, React no longer controls when, or how often, they execute. At that point, the closure becomes a historical record, not a live view of state. The bug exists because the callback’s lifetime is decoupled from the component’s render lifecycle.

The correct update pattern

Instead of using the state variable directly, we switched to a functional update.

setUnread(prev => prev + 1);

Functional updates defer state resolution to React itself. Rather than relying on a captured value, React guarantees that the updater function receives the latest committed state at the moment the update is applied.

This holds regardless of when the callback was created or how many renders have occurred since.

Why this is not a dependency array problem

A common instinct is to add the state variable to the dependency array and recreate the callback on every render. That does not solve the underlying issue. External systems (sockets, timers, event emitters) do not participate in React’s render lifecycle. Re-subscribing on every render introduces different problems: duplicated listeners, missed cleanup, and unnecessary churn. This is a lifetime boundary problem, not a dependency array problem.

Where this shows up in real code

In all of the following cases, React hands control to an external scheduler or event source:

  • - WebSocket and SSE handlers
  • - Event listeners
  • - Timers (setInterval, setTimeout)
  • - Promise chains
  • - Background subscriptions
  • - External libraries calling back into React

If control leaves React, closures can go stale.

Tip

If the code runs later or your state update depends on the previous value, use a functional update. They are the simplest and most reliable solution here. Alternatives exist, like reducers, refs, or moving the subscription boundary outside React entirely, but the invariant remains the same.

Final note

This behavior is not a React quirk. React simply makes JavaScript’s closure semantics more visible by encouraging asynchronous and long-lived interactions. Once you reason in terms of render lifetimes vs callback lifetimes, this entire category of bugs becomes predictable and avoidable.