react-strict-mode-async-race-condition
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
setStateon 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:
// BEFORE (vulnerable to race condition):
if (!abortController.signal.aborted) {
setSession(data);
setPhase("connecting"); // Blindly overwrites whatever phase is current
}
// AFTER (safe):
if (!abortController.signal.aborted) {
setSession(data);
setPhase((current) => {
// Only transition if we haven't already progressed past this phase
if (current === "loading") {
return "connecting";
}
// Late-arriving async result is a no-op for phase
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);
// Never regress to an earlier phase
if (targetIndex <= currentIndex) return current;
return targetPhase;
});
}
Verification
- Run the app in dev mode (
npm run devwithreactStrictMode: 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.currentprevents 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
- React Strict Mode - official docs on double-execution behavior
- useState functional updates - React docs on functional updaters