| name | react-strict-mode-async-race-condition |
| description | Fix state regression bugs caused by React Strict Mode double-execution racing
with async operations (API fetches, WebSocket connections). Use when: (1) UI
gets stuck in a loading/connecting state in dev but works in production, (2)
a state setter overwrites a more advanced state with a stale value, (3) an
effect consumes a one-time resource (memory store, one-time token) on first
mount leaving the second mount to fall through to a slower async path. The
fix uses React functional state updaters to prevent phase/state regression.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-17T00:00:00.000Z" |
React Strict Mode Async Race Condition
Problem
React Strict Mode double-executes effects (mount -> unmount -> mount) to help
surface bugs. When an effect has side effects that consume one-time resources
(e.g., removing startup data from a memory store), the second mount falls through
to a slower async code path. If a fast path (e.g., WebSocket) from mount 1
completes before the slow path (e.g., API fetch) from mount 2, the slow path's
setState call can overwrite the advanced state back to an earlier phase.
Context / Trigger Conditions
- Symptom: UI stuck in loading/connecting state in dev mode, but works in
production (where Strict Mode doesn't double-execute effects)
- Symptom: State appears to "regress" (e.g., goes from "ready" back to
"connecting") visible in React DevTools or debug logs
- Pattern: An effect that (a) reads and removes a one-time value from a
store, then (b) falls through to an API fetch if the value is missing
- Pattern: Multiple async operations (WebSocket + API fetch) that both call
setState on the same state variable
- Trigger: Often exposed by seemingly unrelated changes that alter the SSR
or hydration path (e.g., adding try-catch around a server-side call that
previously threw, changing middleware behavior)
Root Cause Sequence
1. Mount 1: reads startup data from memory store -> setState("connecting")
-> starts WebSocket connection
-> REMOVES startup data from memory store (side effect)
2. Strict Mode unmount: cleanup fires abortController.abort()
3. Mount 2: startup data is GONE (removed in step 1)
-> falls through to API fetch path (slower)
-> WebSocket ref persisted from mount 1
4. WebSocket connects (fast) -> setState("ready")
5. API fetch completes (slow) -> setState("connecting") -- OVERWRITES "ready"!
6. Connection effect sees connectionAttemptedRef.current === true -> skips
7. RESULT: stuck at "connecting" forever
Solution
Replace direct setState(value) calls with functional updaters that check
the current state before transitioning:
if (!abortController.signal.aborted) {
setSession(data);
setPhase("connecting");
}
if (!abortController.signal.aborted) {
setSession(data);
setPhase((current) => {
if (current === "loading") {
return "connecting";
}
return current;
});
}
Why This Works
React's functional updater setState(prev => ...) receives the latest state
at execution time, not the stale value captured in the effect's closure. This
means even if the API fetch resolves after the WebSocket has already advanced
the state, the updater sees the current state and can decide not to regress it.
General Pattern
For any state that progresses through ordered phases:
const PHASE_ORDER = ["loading", "connecting", "ready", "active", "complete"];
function safePhaseTransition(targetPhase: string) {
setPhase((current) => {
const currentIndex = PHASE_ORDER.indexOf(current);
const targetIndex = PHASE_ORDER.indexOf(targetPhase);
if (targetIndex <= currentIndex) return current;
return targetPhase;
});
}
Verification
- Run the app in dev mode (
npm run dev with reactStrictMode: true)
- Trigger the affected flow (e.g., start a session)
- Verify the UI progresses past the loading/connecting state
- Check console logs for the "already progressed" debug message to confirm
the guard is working
- Verify production build still works (production doesn't double-execute)
Why This Is Dev-Only
React Strict Mode only double-executes effects in development. Production
builds run effects once. This means:
- The bug only manifests in dev mode
- The fix is still correct in production (functional updater just runs once)
- Don't dismiss the bug as "dev-only noise" -- it reveals a real race condition
that could manifest in production under different timing conditions
Related Patterns
- AbortController cleanup: Always abort in-flight requests in effect cleanup.
This prevents the request from completing, but doesn't help if the request
completes before cleanup fires (which happens with Strict Mode's synchronous
unmount/remount).
- Ref-based guards:
connectionAttemptedRef.current prevents double
connection attempts, but doesn't prevent state overwrites from already
in-flight async operations.
- Memory store / one-time tokens: If an effect consumes a value on first
read (e.g.,
memoryStoreGet + memoryStoreRemove), consider making the
read idempotent or deferring the removal until the effect is confirmed to
be the "real" mount.
Notes
- This pattern is not limited to React Strict Mode. Any scenario where multiple
async operations race to update the same state can exhibit this behavior.
Strict Mode just makes it reliably reproducible.
- The functional updater approach is recommended by the React team for any state
update that depends on the previous state.
- Seemingly unrelated changes (middleware fixes, SSR error handling) can expose
this bug by changing the hydration path, which changes whether Strict Mode
double-execution triggers the race condition.
References