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.