Skip to Content
React in a Week
Day 4 · State & escape hatches

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:

api.js (real version)
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:

Live code· Search, first attempt (has a bug)

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.

Live code· Search with loading, error and cleanup

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.

Quick check

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.

Live code· Reading List: SearchSuggestions

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.

Try it

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

0/10

On this page