Fetching Data with useEffect
The classic pattern for loading data into a component — loading and error states, and avoiding race conditions on fast-changing props.
2 min read
The traditional way to load data into a React component is a fetch call inside useEffect, tracking the request's progress with a bit of local state:
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) => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <Profile user={user} />;
}userId in the dependency array means this effect re-runs — and re-fetches — every time the component receives a different userId prop.
The race condition this pattern hides
If userId changes quickly (the user clicks between two profiles fast), two fetches can be in flight at once, and there's no guarantee they resolve in the order they were sent. If the first request resolves after the second, its stale response overwrites the correct, newer data.
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data); // skip stale responses
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true; // the previous effect's response is now stale
};
}, [userId]);The cleanup function runs before the effect re-runs, flipping ignore to true for the previous request's closure — so if that older fetch resolves later, its .then checks ignore and discards the result instead of overwriting newer state. An AbortController accomplishes the same thing more directly, by actually canceling the outdated request rather than just ignoring its result.
Why this isn't the whole story anymore
This pattern is worth understanding because it's still common in existing codebases and it teaches the underlying mechanics — dependency arrays, cleanup, race conditions — clearly. But writing it by hand for every component misses things a dedicated data-fetching library handles for you: caching so the same data isn't re-fetched needlessly, retries, deduping simultaneous requests for the same resource, and background refetching. In practice, most production React codebases use a library like TanStack Query or SWR for anything beyond the simplest fetch, and reach for useEffect fetching directly only for one-off cases. If you're building on Next.js specifically, Server Components (covered later in this course) remove the need for this pattern entirely for a large class of data fetching.
Next, the Advanced Patterns section starts with the Context API — for sharing data across many components without passing it through props at every level.