#Fetching in an effect
The most basic way to load data in React is to start a request in useEffect after the component renders, keep the result in state, and render one of three things: a loading message, an error, or the data. Every React developer has written this, and interviewers like it because the naive version has three bugs hiding in it.
The mental model: an effect is a subscription to "the props this render used". When userId changes, the old subscription (the old request) must be torn down and a new one started. If you forget the teardown, the old request is still out there, and whichever response arrives last wins, even if it's for the user you navigated away from. That's the race condition.
import { useEffect, useState } from "react";
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setUser(data);
setIsLoading(false);
})
.catch((err) => {
if (err.name === "AbortError") return; // we cancelled it ourselves
setError(err);
setIsLoading(false);
});
return () => controller.abort();
}, [userId]);
if (isLoading) return <p>Loading…</p>;
if (error) return <p role="alert">Error: {error.message}</p>;
return <h2>{user.name}</h2>;
}- First render with
userId = 1:isLoadingstarts astrue, so the component returns<p>Loading…</p>. Effects run after the browser has the DOM, so the request hasn't started yet when the loading text first appears. - The effect runs: it creates an
AbortController, resetsisLoading/error(they matter on the second id, not the first), and callsfetch("/api/users/1", { signal }). Thesignalties this request to this controller. if (!res.ok) throw …is there becausefetchonly rejects on network failure. A 404 or 500 resolves normally withres.ok === false. Without this line a 404 body would be stored as the "user". In the test, a 404 rendersError: HTTP 404.- Now the parent re-renders with
userId = 2before user 1 has arrived (in the test, user 1 takes 100 ms and user 2 takes 10 ms). React runs the cleanup from the previous effect first:controller.abort()for request 1. Its promise rejects with anAbortError, which thecatchignores. Then the new effect starts request 2. - Request 2 resolves →
setUser({ name: "Grace" }). Request 1 never callssetUser, so 150 ms later the heading still says "Grace". The test also checks the signals: request 1aborted: true, request 2aborted: false. - If you deleted the cleanup, both requests would complete and the slower one (user 1, "Ada") would land last and overwrite "Grace". The screen would show the wrong user for the URL you're on.
In development with <StrictMode>, React mounts, unmounts and re-mounts every component once to flush out missing cleanups. With this component, the test records two requests: the first is aborted by the cleanup (aborted: true) and the second one does the work (aborted: false). That's expected and harmless; see Strict Mode runs effects twice. If you see two completed requests in Strict Mode, your cleanup is missing.
Not every async API takes an AbortSignal (a third-party SDK, a setTimeout chain). The older pattern, still common in codebases and in the React docs, is a local boolean that the cleanup flips:
useEffect(() => {
let ignore = false;
fetchUserFromSdk(userId).then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true;
};
}, [userId]);- Each effect run gets its own
ignorevariable, captured by its own.thencallback (a closure). - When
userIdchanges, the old effect's cleanup sets itsignoretotrue. The new run has a freshignore = false. - The stale response still arrives, but its callback sees
ignore === trueand drops the result. - The difference from
AbortController: the request still runs to completion and uses bandwidth. Aborting actually cancels it. Use abort when you can, the flag when you can't.
Where you'll see this: small apps, one-off widgets, and older code (often wrapped in a useFetch hook; see useFetch: a data-fetching hook). In my notes, the Redux version of the same idea was useEffect(() => { dispatch(fetchUsers()); }, [dispatch]) with loading/error/users read through useSelector: same shape, with the state moved into the store.
What the hand-written version still doesn't do: caching (go back to user 1 and it refetches), deduplication (two components = two requests), retries, refetching when data goes stale or the tab regains focus, pagination, and optimistic updates. Each is a few dozen lines, and they interact. That's the gap libraries like TanStack Query fill, and the reason the React docs recommend a framework or a data library over raw effects for anything non-trivial.