Fetching in effects correctly: races, loading states and waterfalls
If you fetch data in useEffect, handle race conditions with an ignore flag or AbortController, model loading and error states, and avoid request waterfalls. And know why frameworks and TanStack Query are usually better.
Comes up in: React, Machine coding
Learn#
Ravi clicks through profiles quickly. Sometimes the page says “user 2” at the top but shows user 1’s details. The two requests raced, and the slower, older one finished last.
A race condition in an effect happens because each effect run starts a request, but nothing stops an older one from finishing last. The cleanup is the fix: it runs before the next effect, so it can mark the old request as outdated:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); // a DOM for Node; in a browser you already have one
const { createElement: h, useEffect, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
const delays = { 1: 150, 2: 50 }; // user 1's request is the slow one
const fakeFetch = (id) => new Promise((r) => setTimeout(() => r(`profile of user ${id}`), delays[id]));
function Profile({ userId, safe }) {
const [text, setText] = useState("loading…");
useEffect(() => {
let ignore = false;
fakeFetch(userId).then((data) => {
if (safe && ignore) return; // a newer request replaced this one
setText(data);
});
return () => { ignore = true; };
}, [userId, safe]);
return h("p", null, `${safe ? "with ignore flag" : "no guard"}: user ${userId} → ${text}`);
}
for (const safe of [false, true]) {
const box = document.createElement("div");
const root = createRoot(box);
root.render(h(Profile, { userId: 1, safe }));
await new Promise((r) => setTimeout(r, 10));
root.render(h(Profile, { userId: 2, safe })); // the user clicks another profile
await new Promise((r) => setTimeout(r, 300));
console.log(box.textContent);
root.unmount();
}Output (verified)
no guard: user 2 → profile of user 1 with ignore flag: user 2 → profile of user 2
An AbortController aborted in the cleanup also stops the download (JS.11.04).
Network
- GET /users/1 …
Page
empty
userId is 1. The effect starts a request for user 1, which happens to be slow (150 ms).
Read the steps as text
- userId is 1. The effect starts a request for user 1, which happens to be slow (150 ms).
- The user clicks user 2. The cleanup for user 1's effect sets ignore = true; a new effect requests user 2 (50 ms).
- User 2's response arrives first and is shown.
- User 1's response arrives last. Without the flag it overwrites the screen; with it, it is dropped.
Predict#
Predict first
A page fetches the user in a parent effect, then each child fetches its own data in its effect after the user loads. What is the problem?
Children mount only after the parent’s data arrives, so their requests start late. A request waterfall turns three 200 ms requests into 600 ms. Start independent requests together, at the route level, or let a framework load them in parallel.
Interview angle#
Say it like this — about 30 seconds
“Fetching in an effect has three traps. Race conditions: an old request can finish after a new one, so I use an ignore flag or abort the request in the cleanup. Missing states: I model idle, loading, success and error, and keep old data while refetching. And waterfalls: nested components that fetch after their parents load. Raw effects also lack caching and de-duplication, so in real apps I use a framework’s data loading, Server Components, or TanStack Query.”
Follow-up How do you prevent race conditions when fetching data in an effect?
Follow-up What does TanStack Query add over a hand-written useFetch?
Follow-up How do you avoid waterfalls?
Practice#
On your own
Write the logic of a correct useFetch: loading state, abort, and ignoring outdated responses.
Done when
- Each load reports loading, then success or error, only if it is still the latest.
- The previous request is aborted, and its AbortError is never reported.
cancelstops reporting entirely, like an unmount.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
How does an effect avoid showing a stale response?
An ignore flag or AbortController set in its cleanup.
Why can't the effect function itself be async?
It must return a cleanup function or nothing, not a promise.
What is a request waterfall?
Requests that start one after another because each waits for the one above.
Name a library that handles caching and races for you.
TanStack Query (or SWR).
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
Check yourself: Which four states should a fetch UI model?
Idle, loading, success and error.
How it works underneathSenior rounds
Abort stops the download; the ignore check covers responses that already arrived or APIs that ignore the signal. Report AbortError as nothing, not as a failure Source: React: Fetching data in effects.
Check yourself: Why use both abort and an ignore check?
Abort saves bandwidth; the check covers responses that finish before the abort lands or APIs that ignore the signal.
Edge cases and trapsWhere people slip
In development, StrictMode runs the effect twice, so you see two requests; with a correct cleanup, the first is ignored or aborted. Production sends one.
Check yourself: What does StrictMode do to a fetch effect in development?
Runs it twice; the first request is cleaned up (ignored or aborted).
In productionStaff-level answers
Check yourself: Where should data for a route ideally be fetched?
At the route or server level, in parallel, not deep in nested effects.
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-11-01. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- How do you prevent race conditions when fetching data in an effect?