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.
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.
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.
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
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.
Frequently Asked Questions
What is a race condition in data fetching?
How does the ignore flag work?
Should I use AbortController instead?
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.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.