Topic 7.7
Fetching Data in an Effect
In one line
You can load data in an effect by tracking loading, error and success states, but you must stop old responses from overwriting new ones (a race condition) with an ignore flag or an AbortController; in real apps a framework or a data library usually does this better.
Think of it like this
Ordering food from two restaurants. You order pizza, change your mind and order noodles. The noodles arrive first, then the slow pizza driver rings the bell. If you just eat whatever comes last, you end up with pizza you no longer wanted. You need a rule: "only accept the delivery for my latest order". Data fetching in React needs the same rule.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Fetch
- Ask a server for data over the network.
fetch(url)returns a promise. - Promise
- An object for a value that will arrive later (or fail).
awaitwaits for it. - Loading state
- What the UI shows while waiting for data.
- Race condition
- A bug where the result depends on which of two operations finishes first.
- Stale response
- A response for an old request that arrives after the inputs changed.
- Ignore flag
- A per-run variable the cleanup sets to
trueso that run's late response is thrown away. - AbortController
- A browser object whose
abort()cancels the requests that were given itssignal. - AbortError
- The error a cancelled
fetchrejects with. You ignore it on purpose. - Waterfall
- Requests that run one after another because each starts only after the previous component rendered.
Step by step
01Start the request in an effect
The effect depends on userId, so it runs again when the user changes. The async function lives inside the effect.
useEffect(() => {
async function load() {
const res = await fetch("/api/users/" + userId);
const data = await res.json();
setUser(data);
}
load();
}, [userId]);02Model loading, error and success
One status value decides what to show. fetch only rejects on network failure, so check res.ok yourself for HTTP errors like 404 and 500.
const [status, setStatus] = useState("loading");
// inside load():
try {
const res = await fetch("/api/users/" + userId);
if (!res.ok) throw new Error("HTTP " + res.status);
setUser(await res.json());
setStatus("success");
} catch (err) {
setError(err);
setStatus("error");
}03See the race
Two requests are in flight. Whichever finishes last wins, and that may be the old one.
04Fix it with an ignore flag
Every run has its own ignore. The cleanup for the user 1 run sets that run's flag before the user 2 run starts, so user 1's late response is dropped.
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);05Or cancel it with AbortController
Aborting also cancels the network request in the browser. Ignore the AbortError it causes.
useEffect(() => {
const controller = new AbortController();
fetch("/api/users/" + userId, { signal: controller.signal })
.then((res) => res.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [userId]);06Know when to hand it to a library
For anything beyond a few requests, a data library gives you caching, de-duplication, retries and race safety for free. This is the same Profile with TanStack Query (Phase 14).
import { useQuery } from "@tanstack/react-query";
function Profile({ userId }) {
const { data, status, error } = useQuery({
queryKey: ["user", userId],
queryFn: ({ signal }) =>
fetch("/api/users/" + userId, { signal }).then((r) => r.json()),
});
if (status === "pending") return <p>Loading…</p>;
if (status === "error") return <p role="alert">{error.message}</p>;
return <p>Hello, {data.name}!</p>;
}Try it yourself
- 1
Make the old request fast
In the race example, swap the delays (user 1: 100 ms, user 2: 600 ms). Predict the final screen. (Ravi: the bug only shows when the old request is slower, which is why it slips through testing.)
- 2
Add a retry button
In the first example, show a
Retrybutton next to the error. Clicking it should load the same user again. (Hint: keep aretrycounter in state and add it to the effect's dependencies, or giveProfilea key that includes it.) - 3
Abort on unmount
In the AbortController example, add a button that hides
Profilewhile user 1 is loading. Predict the log. (request for user 1 aborted: cleanup runs on unmount too.)
Code & diagrams
User 1 loads, then the script picks user 3, who doesn't exist. `key={userId}` gives each user a fresh `Profile` starting at `loading` (Topic 7.6), so the old user's name never shows under the new id. `role="alert"` makes screen readers announce the error.
Preview
Console
Nothing logged yet.Expected console output
render user 1 -> loading
render user 1 -> success
render user 3 -> loading
render user 3 -> errorThe bug: the page says user 2 is selected but shows Asha (user 1). The fast request for user 2 finished first; the slow, outdated request for user 1 finished last and overwrote it.
Preview
Console
Nothing logged yet.Expected console output
request user 1 (600 ms)
request user 2 (100 ms)
response for user 2 -> setUser
response for user 1 -> setUser
Selected user 2, screen shows: AshaSame timing, correct result. Choosing user 2 ran the user 1 run's cleanup, which set *that run's* `ignore` to true. The slow response still arrives but is dropped.
Preview
Console
Nothing logged yet.Expected console output
request user 1 (600 ms)
request user 2 (100 ms)
response for user 2 -> setUser
response for user 1 -> ignored (stale)
Selected user 2, screen shows: RaviThe cleanup aborts user 1's request the moment user 2 is chosen, so it never completes. A real `fetch` given the same `signal` cancels the HTTP request in the browser and rejects with an `AbortError` in the same way.
Preview
Console
Nothing logged yet.Expected console output
request user 1
request user 2
request for user 1 aborted
showing Ravi
screen shows: RaviBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Make the effect itself async
Write useEffect(async () => { const data = await fakeFetchUser(userId); setUser(data); }, [userId]);
Myth vs fact
Myth
Race conditions need two users or a busy server.
Fact
One user clicking quickly is enough, whenever an older request can take longer than a newer one, which networks do all the time.
Myth
fetch rejects when the server returns 404 or 500.
Fact
fetch rejects only for network failures (and aborts). Check res.ok or res.status yourself.
Myth
Fetching in effects is wrong.
Fact
It's a valid, simple approach for small apps, as long as you handle loading, errors and races. Frameworks and libraries add caching, preloading and server rendering on top.
Myth
The ignore flag cancels the request.
Fact
It only discards the result. Use AbortController to actually cancel the network request.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Effects start fetching only after commit, so a parent and child that both fetch form a waterfall. Route loaders (React Router, Next.js) and prefetching in TanStack Query start requests before or in parallel with rendering. Server Components fetch on the server with no client waterfall (Phase 20).
- ▸
An
AbortSignalcan be shared: pass it to severalfetchcalls and toaddEventListenerin the same effect, and oneabort()cleans up all of them.AbortSignal.timeout(ms)andAbortSignal.any([...])combine timeouts with cancellation. - ▸
In StrictMode development each fetch effect runs twice; with an ignore flag or abort the first is discarded (Topic 7.4). Data libraries de-duplicate identical in-flight requests, so you see one request.
- ▸
React 19's
use(promise)with Suspense moves loading states to<Suspense fallback>boundaries and errors to error boundaries, but the promise must be cached (created outside render, e.g. by a library or framework), or every render creates a new request (Phase 14).
Remember this
- 1
Fetching means asking a server for data over the network, usually with
fetch(url), which returns a promise (an object for a value that arrives later; see /javascript promises and async/await). Showing data from a server is syncing with an outside system, so an effect is a valid place to start the request when a component appears or when its inputs (likeuserId) change. - 2
A request has three states the screen must show: loading (waiting), error (it failed: show a message, maybe a retry button) and success (show the data). Model them with one
statusvalue, not separate booleans that can contradict each other (Topic 6.1). The effect function itself can't beasync(it must return nothing or a cleanup), so define anasync function load()inside it and call it. - 3
A race condition is a bug where the result depends on which of two things finishes first. With fetching: the user picks user 1 (slow request) then user 2 (fast request). The effect starts both. User 2's response arrives first and is shown; then user 1's late response arrives and overwrites it. The screen now says user 2 is selected but shows user 1's data. It happens in real apps whenever responses take different times, which they always do.
- 4
Fix 1: the ignore flag. Each run of the effect declares
let ignore = false; the cleanup sets it totrue. When a response arrives, it's used only if its own run's flag is stillfalse. Because cleanup runs before the next run (Topic 7.3), every older run is marked stale as soon asuserIdchanges, and on unmount. This is the pattern React's docs teach. - 5
Fix 2: AbortController.
const controller = new AbortController(); passcontroller.signaltofetch(url, { signal }); callcontroller.abort()in the cleanup. The browser actually cancels the request (saving bandwidth), and the promise rejects with an error whosenameis"AbortError", which you ignore. Aborting while the body is still being read makesres.json()reject with the same error, so the stale data never reachessetState. - 6
Why frameworks and data libraries do it better: effects only start fetching after the component renders, so nested components cause waterfalls (parent loads, renders the child, which only then starts loading). Effects don't run on the server, there's no caching (going back re-fetches), and you must hand-write loading, error, retry and race handling. Next.js and React Router load data before rendering (Phase 13, Phase 20), and TanStack Query caches, de-duplicates and refetches (Phase 14). React 19's
usewith Suspense is another route. Fetching in effects is fine for small apps and is exactly what those tools do under the hood, so understanding it matters.
Explain it without notes
How would you fetch data in a useEffect? Cover loading, error and success.
Describe a race condition in data fetching and two ways to prevent it.
Why can't the effect function be async?
Why do frameworks and data libraries usually fetch better than hand-written effects?
Practice
Write a useEffect that loads /api/posts?page= + page with fetch, ignores stale responses, and stores the JSON in posts.
Rewrite the previous answer with AbortController.
Trade-offs
- ↔
Hand-written effect fetching has no dependencies and is easy to read, but you rebuild caching, retries and race handling yourself; past a handful of requests a data library pays for itself.
- ↔
AbortController saves bandwidth and server work but needs error filtering; the ignore flag is simpler but lets the request finish.
Done when you can
I can fetch in an effect with loading, error and success states.
I can explain and reproduce a fetch race condition.
I can fix races with an ignore flag and with AbortController.
I never make the effect function itself
async.I check
res.okfor HTTP errors.I can explain why frameworks and data libraries fetch better.