How to fix race conditions in React data fetching.

A user changes a filter twice, two requests fly, and the slower, older one lands last. Your component now shows data for a choice the user already undid. Here is why it happens and four ways to stop it.

Why useEffect fetches go stale

Here is the classic version. It looks correct and passes every test on a fast network:

function Results({ query }) {
  const [items, setItems] = useState([]);
  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then((r) => r.json())
      .then(setItems);            // whichever response arrives last wins
  }, [query]);
  return <List items={items} />;
}

Each change of query starts a new request, but nothing cancels the old one. Network latency is not ordered: a request for "pa" can take 1,200 ms while the request for "paris" takes 190 ms. When the old response finally arrives, setItems overwrites the correct results with stale ones. No error is thrown, so error monitoring never sees it.

The same bug appears with tabs, pagination, filters, autosave and anything else where a newer request supersedes an older one.

Fix 1: an ignore flag in the effect cleanup

React runs the cleanup function before the next effect. Use it to mark the old request as obsolete:

useEffect(() => {
  let ignore = false;
  fetch(`/api/search?q=${query}`)
    .then((r) => r.json())
    .then((data) => { if (!ignore) setItems(data); });
  return () => { ignore = true; };
}, [query]);

This is the pattern the React docs recommend. It fixes the stale write, but the old request still runs to completion and still costs bandwidth.

Fix 2: cancel with AbortController

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setItems)
    .catch((e) => { if (e.name !== "AbortError") throw e; });
  return () => controller.abort();
}, [query]);

Aborting stops the browser from reading the response, and you skip the stale update. Remember to swallow the AbortError, or it will show up as a noisy failure.

Fix 3: request IDs for shared state

Cleanup only helps when the request lives inside one component. When responses write into a global store, such as Redux, Zustand or a context, tag each request and only accept the latest:

let latest = 0;
async function loadResults(query) {
  const id = ++latest;
  const data = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  if (id === latest) store.setState({ results: data });
}

Libraries like TanStack Query and SWR do this bookkeeping for you per cache key, which is one of the best reasons to use them.

Fix 4: let the runtime catch the ones you missed

The fixes above work where you remember to apply them. Real apps have hundreds of fetches written by different people over years. GenClass sits underneath all of them: it versions every field in your store, sees a response about to overwrite newer data, and in guard mode discards it before your app renders it.

import { GenClass } from "@genclass/runtime";
GenClass.init({ mode: "guard" });

It logs what it did in plain English, and the discard can be undone:

[GenClass] Prevented a stale write: GET /api/search?q=pa landed after q=paris was shown (stale; discard)
Try it: the live demo races a search box against an out-of-order network, with the real runtime deciding on your device.

Checklist

  • Every useEffect fetch has a cleanup: an ignore flag or abort().
  • Writes to shared stores check that the response is still the latest.
  • Use a data-fetching library with per-key deduplication where you can.
  • Test with network throttling and random latency, not just a fast local API.
  • Run a runtime safety net in production for the cases you missed.

Related: out-of-order responses in search and autocomplete and preventing duplicate form submissions.