How to Fetch Data with useEffect Without Creating Race Conditions

Fetching in useEffect can apply a stale response over a newer one. Use an ignore flag in the cleanup function to drop outdated results.

6 min read

Fetching data inside useEffect creates race conditions when responses arrive out of order. An older request can resolve after a newer one and overwrite it, so the page shows data for the wrong item. The fix is an ignore flag that the cleanup function flips when the request is no longer needed.

Race conditions matter most when a user can change the request quickly, such as a search box or a detail page. The guard below keeps the newest selection on screen.

Where the race condition comes from

A component that fetches on every prop change sends a new request each time. Nothing guarantees the responses come back in the same order. Each render starts its own request, and the browser resolves them independently.

App.jsxApp.jsx
import { useEffect, useState } from "react";
 
function Bio({ personId }) {
  const [bio, setBio] = useState(null);
  useEffect(() => {
    fetch(`/api/people/${personId}`)
      .then((res) => res.json())
      .then((data) => setBio(data.bio));
  }, [personId]);
  return <p>{bio ?? "Loading..."}</p>;
}

If the user switches from Alice to Bob, the request for Alice can finish after the request for Bob. The page then shows Alice's bio under Bob's name because the stale response called setBio last.

The same failure happens when a user types quickly into a search box. Each keystroke fires a request, and a slower old response can land last. Any response that is not the latest must be treated as stale.

Ignore stale responses with a cleanup flag

Add a local ignore variable and set it to true in the cleanup. Each Effect gets its own copy, so only the latest request keeps ignore set to false. The variable is a plain closure value, not React state, because it only needs to guard one request.

App.jsxApp.jsx
import { useEffect, useState } from "react";
function Bio({ personId }) {
  const [bio, setBio] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch(`/api/people/${personId}`).then((res) => res.json()).then((data) => {
      if (!ignore) setBio(data.bio);
    });
    return () => { ignore = true; };
  }, [personId]);
  return <p>{bio ?? "Loading..."}</p>;
}

When personId changes, React runs the cleanup, which marks the previous Effect's ignore flag as true. Its response arrives later and is dropped. Only the newest request writes to state, so the page always matches the selected person.

Strict Mode also runs setup, cleanup, setup in development, and the flag keeps the first request from writing state. This is the same guard that makes the double-run harmless.

Also cancel the request

The ignore flag fixes the state, but the abandoned request still consumes network. Passing an AbortController signal lets the browser cancel it before the response arrives.

App.jsxApp.jsx
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();

Canceling is an optimization, not a replacement for the flag. A request can resolve between the cleanup and the abort, so the ignore check still belongs in place.

AbortController also works with timeouts and streaming responses. Canceling fetch requests with AbortController has the full walkthrough.

Handle loading, empty, and error states

A complete fetch also tracks loading and failures. Keep a status value so the UI can show a spinner, a message when no data exists, and an error when the request fails.

An empty list is a success with zero items, not an error, so render it separately. Client-side fetch does not run on the server, so the first paint shows the loading state before the data arrives.

When a data library is better

Manual fetching gets repetitive once you add caching, retries, and deduplication. TanStack Query and similar libraries handle races, caching, and errors for you. They also cache responses, so navigating back shows the previous data instantly instead of waiting for another request.

If you use a full-stack React framework, its built-in data fetching is usually the best choice because it runs on the server and avoids waterfalls. For one-off requests in a small client-only app, the ignore flag above is enough.

What to learn next

The same cleanup pattern applies to every Effect that starts work. A custom useFetch hook can wrap the flag so every component does not repeat it. This keeps each component focused on rendering.

See how to use async functions inside useEffect and cleaning up timers, listeners, and subscriptions.

Rune AI

Rune AI

Key Insights

  • Responses can arrive out of order and overwrite newer data.
  • Add an ignore flag and flip it in the cleanup function.
  • Skip the state update when the flag is true.
  • AbortController cancels requests but the flag still guards state.
RunePowered by Rune AI

Frequently Asked Questions

What is a race condition in data fetching?

Two requests race each other when an older response arrives after a newer one. The stale response then overwrites the newer data in state.

How does the ignore flag work?

Each Effect owns an ignore variable. The cleanup sets it to true, so an outdated response checks the flag and skips the state update.

Should I use AbortController instead?

AbortController cancels the request, which saves bandwidth, but the ignore flag is the reliable guard against stale state updates.

Conclusion

Fetching inside useEffect is fine for small client-only apps, but you must guard against out-of-order responses. An ignore flag set by the cleanup function keeps stale results from overwriting newer ones.