Skip to content
wannacode

Data from an API in a component

In short: Data from an API in a React component is loaded from useEffect with a “loading”, “error” or “ready” state, and an AbortController in the cleanup stops the stale request.

Problem

Apples under "Drinks"

The catalog fetches the products of the category from the API. Here the server answers "Fruit and vegetables" in 1.5 seconds and the other categories in 0.3.

React

Press "Fruit and vegetables" and right away "Drinks". The drinks appear. A second later apples, bananas and tomatoes take their place, while the heading and the pressed button still say "Drinks". The buyer sees the wrong products and does not know why.

The idea

A request is an effect

The request leaves the component for the server. That makes it a side effect, and its place is in the effect, with the category as a dependency: every new category sends a new request.

The states are those from the lesson on fetch in the page: loading, error, ready. Here they are one piece of state, status, and the component renders the branch that matches it, as in the lesson on conditional rendering.

useEffect(() => {
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
setStatus('error');
}
}
load();
}, [categoryId]);

The effect function cannot be async: it has to return the cleanup, and an async function returns a promise. That is why load is a separate async function inside the effect, called right away.

status becomes 'loading' at the start of every load, not only the first time. Otherwise, on a change, the old list stays under the new heading until the answer comes.

Try it

Load the category

Catalog has the states products and status, but nothing fills them. Load the products of categoryId from /api/products?category=… and show "Loading…" on every load. The check looks at the dairy (5 products), then presses "Drinks" and waits for "Loading…" and 4 products.

React

The idea

The race

Every press changes the category and runs the effect again. Every run sends its own request and waits for its own answer. None of them knows about the others.

Answers do not come in the order of the requests. The server may search the fruit for longer, the network may slow one of them down. So when the buyer switches fast, two requests are on their way at once. The one that answers last wins, not the one that left last.

This is called a race condition. It does not show while the server is fast and even. It shows on a slow network, on a phone, on a busy server.

See it

See two requests in flight

The bars grow while a request is travelling. First without a cleanup, then with one.

useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const url = `/api/products?category=${categoryId}`;
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setProducts(await response.json());
setStatus('ready');
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId]);
Step by step, in words
  1. The buyer presses "Fruit and vegetables". The category changes, the effect runs and sends a request. This one is slow.
  2. Before it answers, the buyer presses "Drinks". The effect runs again and sends a second request. Now two requests are in flight.
  3. The fast request answers first: the list shows the drinks.
  4. Say the cleanup is missing. The slow request answers last, its run also sets the products, and under "Drinks" there is fruit.
  5. Now with the cleanup. Every run of the effect makes its own AbortController and passes its signal to fetch.
  6. The new category calls the cleanup of the old run: abort() stops the slow request. Its fetch throws AbortError, and catch skips it: that is not an error.
  7. Only the request for the drinks answers. The list and the heading match.
  8. The rule: a request in an effect has a cancel in its cleanup. The last run wins, because every run before it was stopped.

Without a cleanup the screen shows the last answer. With a cleanup the old request is stopped before it answers, and the last answer is always for the last category.

The idea

Cancel in the cleanup

The effect already has a place for "stop the old one": the cleanup. The browser has a tool for stopping a request: AbortController.

  1. Every run of the effect makes its own controller = new AbortController().
  2. It passes its controller.signal to fetch: fetch(url, { signal: controller.signal }).
  3. The cleanup calls controller.abort(). The request stops, and await fetch(…) throws an error named AbortError.
  4. catch skips that error: if (e.name === 'AbortError') return;. The request did not fail; the code stopped it.
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(url, { signal: controller.signal });
…
} catch (e) {
if (e.name === 'AbortError') return;
setStatus('error');
}
}
load();
return () => controller.abort();
}, [categoryId]);

The cleanup also runs when the component goes away. The buyer has left the catalog: the request stops, and nobody sets products in a component that is gone.

Try it

Stop the old request

Make the old request stop when the buyer switches fast. The check presses "Fruit and vegetables" and right away "Drinks", waits for "Loading…" and the drinks, waits out the slow answer and makes sure there are no apples and no error message appeared.

React

Try it

Find the bug

The buyer switches fast and sees "The products did not load." for a moment, then the products come. The server did not return an error. Which line is to blame?

Pick the line with the bug.

Try it

Fill in

What stops the old request?

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal }).then(show);
  return () => controller.();
}, [categoryId]);

The idea

Error and "Try again"

Sometimes the server answers with 500. The check of response.ok throws, catch sets 'error', and the component shows the message with role="alert". The buyer should be able to try again without reloading the page.

Trying again is the same load. The effect already does it when a dependency changes. So "Try again" changes a dependency: a number attempt that only counts the attempts.

const [attempt, setAttempt] = useState(0);
useEffect(() => {
// … the same load
}, [categoryId, attempt]);
<button type="button" onClick={() => setAttempt(attempt + 1)}>Try again</button>

The new run sets 'loading', so the message and the button disappear while the request is travelling.

More precisely, if you are curious

Data libraries. In a real project loading is rarely written by hand in every component. There are libraries that do this and more: they keep the answers in a cache, do not send two identical requests, retry on their own, refresh the data when the buyer comes back to the tab. For React these are most often TanStack Query and SWR. They are not in the sandbox and the course has no lessons on them. Under the hood they do the same as here: a request in an effect, states, a cancel.

A flag instead of a cancel. Instead of an AbortController the cleanup can raise a flag: let ignore = false; in the effect, ignore = true in the cleanup, and if (!ignore) before setting the products. The old request still reaches the server, but its answer is thrown away. A cancel stops the request itself. Both ways solve the race.

StrictMode. In development with <StrictMode> the effect runs, is cleaned up and runs again. That is why the network shows two requests, the first one cancelled. It is the test that the cleanup works.

Exercise

Try again

Here the first request for the dairy gets a 500. The catalog shows "The products did not load.", but there is no way to try again. Add a "Try again" button that runs the load again. The cancel on fast switching has to stay. The check waits for the error, presses "Try again", waits for "Loading…" and 5 products, then switches fast to the fruit and the drinks.

React

Test

Check yourself

1. The buyer presses "Fruit and vegetables", then "Drinks". The answers come in the reverse order. What does code without a cleanup show?
2. Why is the request in an async function load() inside the effect, and not useEffect(async () => …)?
3. Why does catch skip AbortError instead of showing "The products did not load."?
4. How does "Try again" send the request again?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·