Fetching Data

Course 2 · Ch 6
Fetching Data
Doing the loading/error/race-condition handling properly, after using the basics informally since Fundamentals Chapter 8

Fundamentals Chapter 8 introduced fetching with a minimal example; the Weather App project formalized it into a single status value (idle/loading/error/success); the Book Search project flagged, but didn't fix, a real correctness problem — a slow, stale request finishing after a newer one and overwriting it with outdated data. This chapter closes that gap properly, and ends with the same fetch logic extracted into a reusable hook.

Async Inside useEffect — The Right Shape

useEffect(() => { async function loadData() { const response = await fetch(url); const data = await response.json(); setData(data); } loadData(); }, [url]);

The function passed directly to useEffect can never be async itself — React expects that function to return either nothing or a cleanup function, and an async function always returns a Promise instead, which React would mistake for a cleanup function and try to call later. The fix is always the same: define a separate async function inside the effect, and call it immediately.

Proper Error Handling

useEffect(() => { async function loadData() { setStatus("loading"); try { const response = await fetch(url); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } const data = await response.json(); setData(data); setStatus("success"); } catch (err) { setStatus("error"); } } loadData(); }, [url]);

A crucial detail easy to miss: fetch only rejects (triggering catch) on a genuine network failure — a 404 or a 500 response is still a "successful" fetch as far as the Promise is concerned. response.ok is what actually distinguishes a real HTTP success (status 200–299) from an error status, which is why it needs to be checked and thrown manually to land in the same catch block as a true network error.

The Race Condition, Properly Fixed

useEffect(() => { let ignore = false; async function loadData() { setStatus("loading"); const response = await fetch(url); const data = await response.json(); if (!ignore) { setData(data); setStatus("success"); } } loadData(); return () => { ignore = true; }; }, [url]);

This is the fix Project 4 flagged but didn't implement. When url changes before the previous fetch finishes, React runs the cleanup function (from Fundamentals Chapter 8) for the old effect before starting the new one — setting that old effect's own ignore flag to true. When the old, now-stale response finally arrives, if (!ignore) is false, and its outdated data is correctly discarded rather than overwriting the newer request's result.

An alternative: AbortController
AbortController can cancel the underlying network request itself, rather than just ignoring its result after the fact — slightly more efficient, since the browser stops downloading data nobody needs anymore. The ignore-flag approach above is simpler to read and reason about for most cases, and is a perfectly reasonable default; reach for AbortController specifically when actually cancelling the network request (not just its effect) matters.

Extracting useFetch

function useFetch(url) { const [data, setData] = useState(null); const [status, setStatus] = useState("idle"); useEffect(() => { let ignore = false; setStatus("loading"); async function loadData() { try { const response = await fetch(url); if (!response.ok) throw new Error("Request failed"); const result = await response.json(); if (!ignore) { setData(result); setStatus("success"); } } catch (err) { if (!ignore) setStatus("error"); } } loadData(); return () => { ignore = true; }; }, [url]); return { data, status }; } // usage — every component fetching data shrinks to this const { data, status } = useFetch(`/api/products/${id}`);

Everything in this chapter — loading state, error handling, race-condition protection — now lives in exactly one place, exactly the custom-hook extraction principle from Chapter 4. Every component that needs to fetch something calls useFetch(url) and gets correct behavior automatically, instead of re-implementing this same logic (and re-introducing the same bugs) in every single component that fetches data.

This entire chapter, solved for you
Project 7's Dashboard capstone used React Query, covered fully in Advanced Chapter 2 — its useQuery hook handles loading state, errors, race conditions, and caching, all without writing any of the code above by hand. Understanding what's actually happening underneath (this chapter) makes React Query's behavior feel like a natural extension rather than unexplained magic once it's introduced properly.

Coding Challenges

Challenge 1

Build a component that fetches a single resource (any free public API) using the async-function-inside-useEffect pattern, with full try/catch error handling and a status value covering loading/error/success.

📄 View solution
Challenge 2

Build a component that fetches data based on an id prop, with two buttons that rapidly switch between two different ids. Add the ignore-flag cleanup pattern, and confirm (e.g. via an artificial delay) that switching ids quickly never shows the wrong id's stale data.

📄 View solution
Challenge 3

Extract the chapter's useFetch hook and use it in two different components fetching two different URLs, confirming both work independently with their own data/status.

📄 View solution

Chapter 6 Quick Reference

  • Never make the useEffect callback itself async — define an inner async function and call it
  • response.ok must be checked manually — fetch doesn't reject on a 404/500 by itself
  • The ignore-flag pattern (or AbortController) prevents a stale, slow response from overwriting newer data
  • useFetch(url) — extracting all of the above into one reusable custom hook
  • React Query (Advanced Ch 2) automates everything covered in this chapter
  • Next chapter: performance basics — useMemo, useCallback, React.memo