Lesson 14 of 31
Fetching Data in an Effect
The hand-rolled pattern for loading data on mount — loading/error/data state, the race-condition guard, and why real apps usually reach for a dedicated data-fetching library instead.
Fetching data is one of the most common reasons to reach for useEffect —
loading a network resource is a textbook example of "something outside
React's rendering" that needs to run after a component mounts (or after a
prop it depends on changes).
The pattern, built up
Start with the three things the UI needs to distinguish: still loading, failed, or succeeded with data.
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data) => setUser(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <p>{user.name}</p>;
}Each state is reset at the start of every fetch (setLoading(true),
setError(null)) because switching to a new userId should show a loading
state again, not silently keep showing the previous user while a new
request is in flight.
The race condition this is missing
There's a real bug hiding here: if userId changes quickly — the user
clicks through several profiles fast — nothing stops an older, slower
request from resolving after a newer one and overwriting fresh state with
stale data. Fetches don't resolve in a guaranteed order.
The fix, from the cleanup lesson, is to guard against exactly this:
useEffect(() => {
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
})
.catch((err) => {
if (!ignore) setError(err.message);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [userId]);When userId changes again before a request finishes, cleanup sets
ignore to true for that request's closure before the new effect starts
— so even if the old response arrives later, its .then callbacks check
ignore and do nothing. (AbortController, shown in the cleanup lesson, is
the more thorough version of the same idea — it actually cancels the
network request instead of just ignoring its result.)
Why real apps usually don't hand-write this
This pattern is worth understanding deeply, because it's exactly what's
happening under the hood of any tool that fetches data — but writing it out
by hand in every component that needs data gets repetitive and easy to get
subtly wrong (forgetting the guard, forgetting to reset error, and so on).
In practice, most production codebases reach for a dedicated data-fetching
library — React Query and SWR are the two most common — that handles
caching, deduplication of identical in-flight requests, automatic retries,
and race conditions once, consistently, instead of once per component. This
course teaches the effect-based pattern because it's what those libraries
are built on, and because understanding it is what makes a library's
behavior legible rather than magical.
Try it yourself
A simulated fetch (no real network call, so this runs anywhere) that demonstrates the loading → data flow, with the race-condition guard in place:
What to remember
- The standard hand-rolled pattern tracks data, loading, and error as separate states because they represent genuinely different things the UI renders differently.
- Fetches can resolve out of order; a guard (an ignore flag, or an AbortController) prevents a stale response from overwriting a newer one.
- This pattern is the honest mechanics behind data-fetching libraries — most real applications reach for one (React Query, SWR) rather than reimplementing caching and race-condition handling per component.
Check yourself
3 questions · pass 3/3 to unlock useRef for the DOM and Mutable Values
1.In the classic hand-rolled data-fetching effect, why are there usually three separate pieces of state (data, loading, error) instead of just one?
2.Why does fetching in an effect need a race-condition guard when the dependency (like a search query) can change quickly?
3.Why do many production React codebases prefer a library like React Query or SWR over a hand-written fetching effect?
3 left to answer