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.
Console
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.
Console
Solution
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Dairy' },
{ id: 5, name: 'Fruit and vegetables' },
{ id: 6, name: 'Drinks' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Categories">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
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]);
if (status === 'loading') return <p role="status">Loading…</p>;
if (status === 'error') return <p role="alert">The products did not load.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
The effect with the dependency [categoryId] calls load(), which sets 'loading', waits for the answer and sets the products and 'ready'. The check catches []: the dairy loads, and changing the category does nothing.
The check also catches 'loading' set only at the start of the component: on a category change "Loading…" does not appear.
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
- The buyer presses "Fruit and vegetables". The category changes, the effect runs and sends a request. This one is slow.
- Before it answers, the buyer presses "Drinks". The effect runs again and sends a second request. Now two requests are in flight.
- The fast request answers first: the list shows the drinks.
- Say the cleanup is missing. The slow request answers last, its run also sets the products, and under "Drinks" there is fruit.
- Now with the cleanup. Every run of the effect makes its own AbortController and passes its signal to fetch.
- 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.
- Only the request for the drinks answers. The list and the heading match.
- 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.
- Every run of the effect makes its own
controller = new AbortController(). - It passes its
controller.signaltofetch:fetch(url, { signal: controller.signal }). - The cleanup calls
controller.abort(). The request stops, andawait fetch(…)throws an error namedAbortError. catchskips 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.
Console
Solution
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Dairy' },
{ id: 5, name: 'Fruit and vegetables' },
{ id: 6, name: 'Drinks' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Categories">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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]);
if (status === 'loading') return <p role="status">Loading…</p>;
if (status === 'error') return <p role="alert">The products did not load.</p>;
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
The effect makes an AbortController, passes the signal to fetch and returns () => controller.abort(). catch skips AbortError.
The check catches a controller whose signal is not passed to fetch: abort() stops nothing and the apples still come. It also catches a catch that does not skip AbortError: right after "Drinks" the cancel sets 'error', and instead of "Loading…" the page shows "The products did not load." for a moment.
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.
Line 11: every error becomes 'error', the cancel from the cleanup too. Before it there should be if (e.name === 'AbortError') return;: a stopped request has not failed.
Try it
Fill in
What stops the old request?
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(show);
return () => controller.();
}, [categoryId]);abort: controller.abort() stops the request whose signal was passed to fetch, and it throws AbortError.
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> 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.
Console
Solution
App.jsximport { useState } from 'react';
import Catalog from './Catalog.jsx';
const CATEGORIES = [
{ id: 4, name: 'Dairy' },
{ id: 5, name: 'Fruit and vegetables' },
{ id: 6, name: 'Drinks' },
];
export default function App() {
const [categoryId, setCategoryId] = useState(4);
const category = CATEGORIES.find((c) => c.id === categoryId);
return (
<main>
<nav aria-label="Categories">
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={c.id === categoryId} onClick={() => setCategoryId(c.id)}>
{c.name}
</button>
))}
</nav>
<h1>{category.name}</h1>
<Catalog categoryId={categoryId} />
</main>
);
}
Catalog.jsximport { useEffect, useState } from 'react';
import { money } from './money.js';
export default function Catalog({ categoryId }) {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus('loading');
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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, attempt]);
if (status === 'loading') return <p role="status">Loading…</p>;
if (status === 'error')
return (
<>
<p role="alert">The products did not load.</p>
<button type="button" onClick={() => setAttempt(attempt + 1)}>Try again</button>
</>
);
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}, {money(p.price)}</li>
))}
</ul>
);
}
money.jsexport const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
const [attempt, setAttempt] = useState(0), the dependencies become [categoryId, attempt], and on 'error' the component shows the message and a button with onClick={() => setAttempt(attempt + 1)}. The check catches a button that changes attempt while attempt is missing from the dependencies: nothing starts.
The check also catches a button that only sets 'loading' without a new request, and code without the check of response.ok: then the answer with 500 is read as a list of products.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Props: data from above · Conditions: empty, error, loading · State · Effects
- Continues in: Forms in a component · Shared state · Testing components · From the sandbox to a project
- Tasks on this topic: Effects, data and forms