React · intermediate
React effect fetch race: stop older responses overwriting newer state
Playbook for diagnosing and fixing the classic React effect fetch race, where a slower response from an earlier input (effect, query, or dependency change) commits state after a newer response, leaving the UI showing data that does not match the current input. Covers identification, triage sequence, and evidence-conditional fixes grounded in React's documented effect semantics.
The symptoms
- •UI renders data that does not match the currently selected input (e.g., stale search results showing under a new query, or a previous user's profile flashing after switching accounts).
- •Selecting input A, then quickly input B, causes the UI to flash A's content, then briefly B's, then A's again, even though no further user action occurred.
- •Bug is reproducible only when the network response is slow or variable: a fast localhost-style call hides it, a throttled or flaky network exposes it.
- •React DevTools Profiler shows two commits for a single user action, the second commit's state derived from the earlier request's response.
- •Component logic uses useEffect with a dependency that changes per request (query string, id, filter), and the fetcher stores the result directly via setState.
- •No errors are thrown; the failure is a silent state corruption rather than an exception or warning.
Likely causes
- •Effect does not ignore stale responses: useEffect's body does not check whether the input that triggered it is still the current one before calling setState.
- •Missing cleanup function that aborts in-flight work when dependencies change, so an older fetch resolves after a newer one and its setState call wins the last commit.
- •Closure capture mismatch: the effect captures an outdated value of the dependency (e.g., query) but writes to a shared state variable, so the last writer wins regardless of which response arrived first.
- •No request id or generation token is compared before setState, so the component cannot distinguish a stale response from a current one.
- •State stored outside React (module-level cache, singleton store) is updated by both responses, and the older response overwrites the newer value because it resolved later.
First ten minutes
- 01Confirm the symptom shape: identify the input that changes per request (URL param, search box, id) and confirm the displayed data belongs to a different, earlier input.
- 02Open React DevTools and inspect the component whose useEffect issues the fetch; record its dependency array and the setState call inside the effect body.
- 03Check whether the effect returns a cleanup function; if not, this is the primary suspect and the remaining triage is mostly confirming the network timing.
- 04Reproduce with a throttled connection or by inserting an artificial delay so response B arrives before response A; observe whether A's data visibly overwrites B's.
- 05Record the order of network responses in the browser DevTools Network panel and the order of commits in the React Profiler for the same interaction.
- 06Decide whether the fix belongs in the effect (ignore / abort stale) or in the state layer (id-keyed storage) based on where the stale write occurs.
Evidence to collect
- •Dependency array of the useEffect that triggers the fetch, captured from source or DevTools.
- •Order of Network panel requests for the interaction, with response status and timing for each.
- •Order of React commits in the Profiler for the same interaction, with the props/state diff that caused each commit.
- •Source of the setState call inside the effect body: whether it writes the response directly or through a guard.
- •Existence and content of a cleanup function returned by the effect, and whether it calls AbortController.abort().
- •Whether any module-level or external cache is mutated by the response handler in addition to React state.
Where to look
- •React effect boundary: the useEffect (or useLayoutEffect) whose dependency array contains the input that varies per request.
- •Async boundary inside the effect: the .then / await chain that resolves the response and the setState call that consumes it.
- •Browser DevTools Network panel: timing and order of outgoing requests triggered by consecutive input changes.
- •React DevTools Profiler: the commit lane for the affected component, especially commits that occur after a user-visible state change but before the next render.
- •Cleanup boundary: the function returned by the effect, which is the documented place to cancel in-flight work when dependencies change.
Diagnostic steps
- 01Inspect the effect's dependency array and confirm it includes every input value that, when changed, should invalidate the in-flight fetch.
- 02Inspect the async path: does the setState inside the resolver check that the captured input still equals the current input? If not, this is the primary defect.
- 03Check for an AbortController: if the effect issues fetch, is the controller created per effect run, passed into fetch, and aborted in the cleanup function? Absence of this pattern is a strong indicator.
- 04Confirm scope of state writes: trace whether the response handler only calls a local setState, or also writes to a shared store; shared stores amplify the race because writes are not scoped to the component.
- 05Compare network ordering to commit ordering using DevTools: if the request that resolves later also commits later, the race is confirmed and the fix is to gate or abort the older request.
- 06Distinguish from unrelated causes: confirm the wrong data is genuinely from an older request (matching URL or payload) and not from a cached, identical-looking query.
Common mistakes
- •Adding a debounce to the input without cancelling the older fetch, which reduces but does not eliminate the race because in-flight requests can still resolve out of order.
- •Comparing the captured input to current state using a stale closure, e.g., reading from a ref that is updated only at render time rather than via a per-effect generation token.
- •Moving the fetch into the render body or an event handler and assuming React will discard the result; React does not discard results, the component must do so.
- •Storing the latest request id on the response object but checking it against a ref that is reset on every render, which can make the check always pass.
- •Treating the bug as a backend ordering issue when the backend correctly returns two responses and the UI commits whichever arrives last.
- •Fixing only one of two parallel fetches that share the same effect, leaving the second still able to overwrite newer state.
Safe fixes
- •If the effect has no cleanup: add a cleanup function that aborts an AbortController passed into fetch, so dependency changes cancel the prior in-flight request.
- •If the effect must await a promise that cannot be aborted: capture the input value in a local const at effect entry, then inside the resolver compare it to the current input (or a per-effect generation token) before calling setState; otherwise discard the response.
- •If state is stored in a shared cache: switch to id-keyed storage where the key is the input that varies, and write only when the resolved key still matches the active input.
- •If the input changes very rapidly: combine an AbortController with a generation counter incremented in the effect body and checked in the resolver, so even non-abortable responses are ignored when stale.
- •If multiple effects in the same component race: apply the same guard in each; do not assume one effect's cleanup protects another.
- •Do not 'fix' by suppressing the setState warning or by memoizing the response; both hide the race without resolving it.
Prove the fix
- 01Reproduction harness: with the fix applied, select input A then input B within a window shorter than A's response time; confirm the UI never displays A's data after B's selection, regardless of which response resolves first.
- 02DevTools regression check: in the Profiler, the component's commits for the interaction must reflect B's data only; there must be no commit whose state diff matches A's response payload.
- 03Network regression check: in the DevTools Network panel, an aborted request from the prior input must show status 'canceled' (or equivalent), and its response must not trigger a render.
- 04Negative test: intentionally slow B's response so A resolves first; confirm A's data still does not appear after B commits, proving the guard is not merely ordering-dependent.
- 05Unit-level check: a test that mounts the component, changes the input twice in quick succession with mocked out-of-order resolution, and asserts that the rendered output matches the last input's resolved data.
Prevention and next steps
- •Treat every useEffect that issues a request as a potential race site by default; require either an AbortController cleanup or a stale-check guard before merging.
- •Adopt a per-effect generation token pattern as a code review checklist item for any effect whose resolver calls setState.
- •Keep request state local to the effect unless there is a documented reason to share it; module-level mutable caches expand the blast radius of a race.
- •Add a Profiler-based CI snapshot for components with input-driven fetches, asserting that consecutive input changes do not produce extra commits after the last user-visible state.
- •Document the abort-or-guard expectation next to any helper that wraps fetch inside an effect, so the pattern travels with the abstraction.
Safe commands and checks
grep -n "useEffect" <component-file>
grep -n "AbortController" <component-file>
grep -n "return () =>" <component-file>
grep -nE "setState|set[A-Z][a-zA-Z]*\\(" <component-file>
grep -n "fetch(" <component-file>
grep -nE "\\.then\\(|await fetch" <component-file>