Fetching data

Load data without lying to the user, and without letting an old response win.

12of 20
≈ 12Min read
7Parts

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.

one status, not three booleans
const [status, setStatus] = React.useState("loading");  // loading | ready | error
const [doors, setDoors] = React.useState([]);
const [error, setError] = React.useState(null);

The effect, in full

with the guard that matters
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]);
Without the flag, the slow response wins

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

the one thing everybody gets wrong
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.

AbortController stops the request itself
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.

the key resets everything, including the request
// 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.

the same thing, with a cache
const { data, error, isPending } = useQuery({
  queryKey: ["doors", query],
  queryFn: () => fetchDoors(query),
});

Practice