The four states, again
Every request has the same shape: it is in flight, it succeeded with something, it succeeded with nothing, or it failed. A component that only handles the second one is the reason apps show an empty screen that never resolves.
const [status, setStatus] = React.useState("loading"); // loading | ready | error
const [doors, setDoors] = React.useState([]);
const [error, setError] = React.useState(null);
The effect, in full
React.useEffect(() => {
let ignore = false; // this render's request
setStatus("loading");
fetch(`/api/doors?q=${query}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch does NOT throw on 404
return res.json();
})
.then((data) => {
if (ignore) return; // a newer request has started
setDoors(data);
setStatus("ready");
})
.catch((err) => {
if (ignore) return;
setError(err.message);
setStatus("error");
});
return () => { ignore = true; };
}, [query]);
Type "a", then "ab". Two requests are in flight. If the first one answers last, the list shows the results for "a" while the box says "ab" — and it happens only on a slow connection, which is why it survives to production.
fetch does not throw on an error status
const res = await fetch(url);
// res.ok is false for 404 and 500 — the promise still RESOLVED
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// it rejects only for: no network, DNS failure, CORS, abort
Cancelling for real
The ignore flag stops a stale response being used; AbortController stops it being downloaded. The flag is enough for correctness; the controller also saves the bandwidth, and is what you want for a search box firing on every keystroke.
React.useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name === "AbortError") return; // expected, not an error
setError(err.message);
});
return () => controller.abort();
}, [url]);
Where the request belongs
- In an effect when the data is needed because the component is on screen — a list, a detail page.
- In the handler when it happens because the user did something — saving, deleting, sending. Never write an effect that watches a flag the click set.
- Not in render, ever. Render is a calculation that must be repeatable.
Race conditions have a second form
Changing the key is a blunt but honest way to avoid every stale-state problem at once: the old component is gone, its cleanup ran, and nothing it had can leak into the new one.
// each id gets its own component instance, so state and effects start clean
<Detail key={id} id={id} />
Why this ends up in a library
The effect above is correct, and it is also the smallest version of the problem. Real screens want caching between visits, deduplication of the same request fired from three components, retries, refetching when the window regains focus, pagination, and optimistic updates.
That is what TanStack Query, SWR or a framework loader give you. Write the effect once by hand so you know what they are doing — then use one.
const { data, error, isPending } = useQuery({
queryKey: ["doors", query],
queryFn: () => fetchDoors(query),
});