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.