Fetching data with Effects
Load data in an Effect, show loading and error states, and avoid race conditions with a cleanup flag.
Your Reading List will suggest books as you type, using data from a server. Fetching is a perfect fit for an Effect: the suggestions on screen must stay in sync with the text in the search box, no matter what caused the text to change.
It is also where beginners hit their first real async bug. In this lesson you will write a fetch the straightforward way, watch it break, and then fix it properly.
A fake API to practice with
Real network calls in a tutorial are flaky, so the sandboxes use a small pretend API in api.js. It waits a moment, like a real server would, then returns matching books. Shorter queries take longer to answer, which will make the bug easy to see. Searching for fail always throws an error.
In a real app you would call the network instead. For example, with the free Open Library API:
export async function searchBooks(query) {
const url = 'https://openlibrary.org/search.json?limit=5&q=' + encodeURIComponent(query);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
const json = await response.json();
return json.docs.map((doc) => ({
id: doc.key,
title: doc.title,
author: doc.author_name?.[0] ?? 'Unknown author',
}));
}Note that fetch only rejects on network failures. A 404 or 500 still resolves, so you check response.ok yourself.
Fetching in an Effect
Here is a first attempt. The Effect depends on query, so it runs whenever the text changes:
Type dune quickly. The final results often show books that only match d (like "Dracula") even though the box says "dune". What happened?
You started four requests: d, du, dun, dune. They finished in a different order than they started. The slow d response arrived last and overwrote the correct dune results. This is a race condition: the outcome depends on which request wins the race.
The fix: ignore stale responses
Remember that React runs an Effect's cleanup before running it again. That gives each request a way to know it is out of date:
useEffect(() => {
let ignore = false;
searchBooks(query).then((books) => {
if (!ignore) {
setResults(books);
}
});
return () => {
ignore = true;
};
}, [query]);Each run of the Effect gets its own ignore variable. When query changes, React calls the previous run's cleanup, which sets that run's ignore to true. When its old response finally arrives, it is quietly ignored. Only the response for the latest query is ever shown.
Loading, error and empty states
Real data takes time and sometimes fails. A good fetching component always handles four cases: loading, error, no results and success. Here is the full version. Type fail to see the error state, then click Try again.
The attempt counter is a small trick: it is in the dependency array, so bumping it re-runs the Effect and retries the request. In this fake API fail always fails; with a real server, a retry often succeeds.
What does the ignore flag in the cleanup prevent?
Deep diveCancelling a real fetch with AbortController
The ignore flag discards stale results, but the browser still downloads them. With a real fetch, you can also cancel the request:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((json) => setData(json))
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [url]);Aborting makes fetch reject with an AbortError, which you skip because it is expected.
In real apps, reach for a library or framework
Fetching in Effects works, and you now understand what is going on underneath. But hand-written fetching has gaps:
- No caching. Go back to a screen and it fetches everything again.
- Waterfalls. A parent fetches, renders a child, and only then the child starts its own fetch.
- No server rendering. Effects don't run on the server, so the first HTML has no data.
That is why the React docs recommend using your framework's data fetching (for example Next.js, which you will use on Day 7), or a client-side library such as TanStack Query. On Day 5 you will replace this Effect with TanStack Query's useQuery, which handles loading, errors, caching and retries for you. The concepts from this lesson (dependencies, stale responses, loading and error states) carry straight over.
Project: SearchSuggestions
Now build the component for your Reading List. As you type a title, SearchSuggestions shows matches from the fake API, and clicking Add calls onPick with the book. In the Day 4 checkpoint, App will turn that into an added action.
Notice that adding a book happens in handlePick, an event handler, because it is caused by a click. Only the fetching lives in the Effect, because it must stay in sync with query.
Wait until the user stops typing
Every keystroke starts a request. Make SearchSuggestions wait 300 milliseconds after the last keystroke before searching. This is called debouncing. Hint: a setTimeout is an external system, so it needs a cleanup too.
Show solution
Wrap the request in a timeout and clear it in the cleanup. If the user types again within 300 ms, the cleanup cancels the pending timeout before it ever fires:
useEffect(() => {
if (query.trim().length < 2) {
setSuggestions([]);
setStatus('idle');
return;
}
let ignore = false;
const timeoutId = setTimeout(() => {
setStatus('loading');
searchBooks(query)
.then((books) => {
if (!ignore) {
setSuggestions(books);
setStatus('done');
}
})
.catch(() => {
if (!ignore) setStatus('error');
});
}, 300);
return () => {
ignore = true;
clearTimeout(timeoutId);
};
}, [query]);One cleanup undoes both things the Effect started: the timer and the pending response.
Day 4 progress