Fetching Data
Loading, error, and success states, plus race conditions and cleanup.
What you'll learn
- The three states every request has, and rendering all of them
- Why the effect callback cannot be
async - Race conditions, and the flag that fixes them
- Why your request fires twice in development
Every request has three states
A request is not a value — it is a value that might arrive, or might fail. Code
that only handles success will render undefined at least once and crash on
the first network error.
Model all three from the start:
Click through to user 3 to see the error path. Notice each state is rendered explicitly — no blank screen while loading, and no crash on failure.
The effect cannot be async
useEffect expects its callback to return either nothing or a cleanup
function. An async function always returns a promise, so React would receive
a promise where it expects cleanup:
// Wrong: returns a promise, not a cleanup function.
useEffect(async () => {
const data = await load();
}, []);
// Right: declare an async function inside, then call it.
useEffect(() => {
async function run() {
const data = await load();
setData(data);
}
run();
}, []);
Race conditions
Two requests started in order do not necessarily finish in order. Ask for user 1 then quickly for user 2, and if the first reply is slower you end up showing user 1's data while the UI says 2.
The fix is a flag captured by the effect run: on cleanup, mark it stale, and have the resolved promise check before setting state.
Both rows below ask for the same id; only the second drops stale responses. Click the button several times in quick succession, then wait a couple of seconds for everything to arrive:
The unguarded row ends up saying "asked for 2, showing User 1" — the slow request for user 1 arrived after the fast one for user 2 and overwrote it. The guarded row ignores that late reply and stays on User 2.
The bug needs the switching to happen while an earlier request is still in flight, which is exactly what a user does when they click through a list impatiently. That is what makes it easy to miss in testing and common in production.
Why it fires twice
In development React mounts, unmounts, and remounts each component, so an effect that fetches runs twice — the double network request you have probably noticed in the devtools.
This is the same double-run from useEffect, and the fix is the
same ignore flag. Code that handles a race condition already handles the
double mount. In production the effect runs once.
A real request
Swap the fake for fetch, and the shape is identical. The one addition is
checking response.ok, because fetch only rejects on network failure — a 404
or a 500 is a resolved promise, as noted in
Promises and async/await:
useEffect(() => {
let ignore = false;
const controller = new AbortController();
async function load() {
try {
setIsLoading(true);
const response = await fetch("/api/users/" + userId, {
signal: controller.signal,
});
if (!response.ok) throw new Error("Request failed: " + response.status);
const data = await response.json();
if (!ignore) setUser(data);
} catch (problem) {
if (!ignore && problem.name !== "AbortError") setError(problem);
} finally {
if (!ignore) setIsLoading(false);
}
}
load();
return () => {
ignore = true;
controller.abort();
};
}, [userId]);
AbortController goes one better than ignoring a stale response: it cancels the
request.
That is a lot of ceremony for one list. It is why most projects reach for a data library — React Query, SWR, or a framework's own loader — which handles caching, retries, and deduplication as well. Understanding what they do for you is the point of writing it once by hand.
Common mistakes
- Only rendering success. Handle loading and error explicitly.
asyncon the effect callback. Declare an inner function instead.- No cleanup. Stale responses overwrite fresh ones.
- Assuming
fetchthrows on 404. Checkresponse.ok. - Fetching in a handler and an effect. Pick one owner for the request.
Exercise
This loader has two bugs: nothing renders while the request is in flight, and a rejected promise is unhandled, so the failure is silent. Add loading and error states — id 3 always fails.