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

  1. 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.
  2. 02Open React DevTools and inspect the component whose useEffect issues the fetch; record its dependency array and the setState call inside the effect body.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. 01Inspect the effect's dependency array and confirm it includes every input value that, when changed, should invalidate the in-flight fetch.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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>