Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~27 min· topic 7 of 9

Topic 7.7

Fetching Data in an Effect

In one line

You can load data in an effect by tracking loading, error and success states, but you must stop old responses from overwriting new ones (a race condition) with an ignore flag or an AbortController; in real apps a framework or a data library usually does this better.

Think of it like this

Ordering food from two restaurants. You order pizza, change your mind and order noodles. The noodles arrive first, then the slow pizza driver rings the bell. If you just eat whatever comes last, you end up with pizza you no longer wanted. You need a rule: "only accept the delivery for my latest order". Data fetching in React needs the same rule.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Fetch
Ask a server for data over the network. fetch(url) returns a promise.
Promise
An object for a value that will arrive later (or fail). await waits for it.
Loading state
What the UI shows while waiting for data.
Race condition
A bug where the result depends on which of two operations finishes first.
Stale response
A response for an old request that arrives after the inputs changed.
Ignore flag
A per-run variable the cleanup sets to true so that run's late response is thrown away.
AbortController
A browser object whose abort() cancels the requests that were given its signal.
AbortError
The error a cancelled fetch rejects with. You ignore it on purpose.
Waterfall
Requests that run one after another because each starts only after the previous component rendered.

Step by step

01Start the request in an effect

The effect depends on userId, so it runs again when the user changes. The async function lives inside the effect.

Profile.jsxwhole filejsx
useEffect(() => {
  async function load() {
    const res = await fetch("/api/users/" + userId);
    const data = await res.json();
    setUser(data);
  }
  load();
}, [userId]);

02Model loading, error and success

One status value decides what to show. fetch only rejects on network failure, so check res.ok yourself for HTTP errors like 404 and 500.

Profile.jsxwhole filejsx
const [status, setStatus] = useState("loading");
// inside load():
try {
  const res = await fetch("/api/users/" + userId);
  if (!res.ok) throw new Error("HTTP " + res.status);
  setUser(await res.json());
  setStatus("success");
} catch (err) {
  setError(err);
  setStatus("error");
}

03See the race

Two requests are in flight. Whichever finishes last wins, and that may be the old one.

See the racediagram
Rendering diagram…

04Fix it with an ignore flag

Every run has its own ignore. The cleanup for the user 1 run sets that run's flag before the user 2 run starts, so user 1's late response is dropped.

Profile.jsxwhole filejsx
useEffect(() => {
  let ignore = false;
  fetchUser(userId).then((data) => {
    if (!ignore) setUser(data);
  });
  return () => {
    ignore = true;
  };
}, [userId]);

05Or cancel it with AbortController

Aborting also cancels the network request in the browser. Ignore the AbortError it causes.

Profile.jsxwhole filejsx
useEffect(() => {
  const controller = new AbortController();
  fetch("/api/users/" + userId, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });
  return () => controller.abort();
}, [userId]);

06Know when to hand it to a library

For anything beyond a few requests, a data library gives you caching, de-duplication, retries and race safety for free. This is the same Profile with TanStack Query (Phase 14).

Profile.jsxwhole filejsx
import { useQuery } from "@tanstack/react-query";

function Profile({ userId }) {
  const { data, status, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: ({ signal }) =>
      fetch("/api/users/" + userId, { signal }).then((r) => r.json()),
  });
  if (status === "pending") return <p>Loading…</p>;
  if (status === "error") return <p role="alert">{error.message}</p>;
  return <p>Hello, {data.name}!</p>;
}

Try it yourself

  1. 1

    Make the old request fast

    In the race example, swap the delays (user 1: 100 ms, user 2: 600 ms). Predict the final screen. (Ravi: the bug only shows when the old request is slower, which is why it slips through testing.)

  2. 2

    Add a retry button

    In the first example, show a Retry button next to the error. Clicking it should load the same user again. (Hint: keep a retry counter in state and add it to the effect's dependencies, or give Profile a key that includes it.)

  3. 3

    Abort on unmount

    In the AbortController example, add a button that hides Profile while user 1 is loading. Predict the log. (request for user 1 aborted: cleanup runs on unmount too.)

Code & diagrams

Loading, error and success New tab

User 1 loads, then the script picks user 3, who doesn't exist. `key={userId}` gives each user a fresh `Profile` starting at `loading` (Topic 7.6), so the old user's name never shows under the new id. `role="alert"` makes screen readers announce the error.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render user 1 -> loading
render user 1 -> success
render user 3 -> loading
render user 3 -> error
A real race condition: the old response wins New tab

The bug: the page says user 2 is selected but shows Asha (user 1). The fast request for user 2 finished first; the slow, outdated request for user 1 finished last and overwrote it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

request user 1 (600 ms)
request user 2 (100 ms)
response for user 2 -> setUser
response for user 1 -> setUser
Selected user 2, screen shows: Asha
Fix 1: the ignore flag New tab

Same timing, correct result. Choosing user 2 ran the user 1 run's cleanup, which set *that run's* `ignore` to true. The slow response still arrives but is dropped.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

request user 1 (600 ms)
request user 2 (100 ms)
response for user 2 -> setUser
response for user 1 -> ignored (stale)
Selected user 2, screen shows: Ravi
Fix 2: cancel with AbortController New tab

The cleanup aborts user 1's request the moment user 2 is chosen, so it never completes. A real `fetch` given the same `signal` cancels the HTTP request in the browser and rejects with an `AbortError` in the same way.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

request user 1
request user 2
request for user 1 aborted
showing Ravi
screen shows: Ravi

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Make the effect itself async

Write useEffect(async () => { const data = await fakeFetchUser(userId); setUser(data); }, [userId]);

terminal
$ Console
── what you'll see ──
useEffect must not return anything besides a function, which is used for clean-up.
 
It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately:
 
useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't need props or state
 
Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching

Myth vs fact

Myth

Race conditions need two users or a busy server.

Fact

One user clicking quickly is enough, whenever an older request can take longer than a newer one, which networks do all the time.

Myth

fetch rejects when the server returns 404 or 500.

Fact

fetch rejects only for network failures (and aborts). Check res.ok or res.status yourself.

Myth

Fetching in effects is wrong.

Fact

It's a valid, simple approach for small apps, as long as you handle loading, errors and races. Frameworks and libraries add caching, preloading and server rendering on top.

Myth

The ignore flag cancels the request.

Fact

It only discards the result. Use AbortController to actually cancel the network request.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Effects start fetching only after commit, so a parent and child that both fetch form a waterfall. Route loaders (React Router, Next.js) and prefetching in TanStack Query start requests before or in parallel with rendering. Server Components fetch on the server with no client waterfall (Phase 20).

  • ▸

    An AbortSignal can be shared: pass it to several fetch calls and to addEventListener in the same effect, and one abort() cleans up all of them. AbortSignal.timeout(ms) and AbortSignal.any([...]) combine timeouts with cancellation.

  • ▸

    In StrictMode development each fetch effect runs twice; with an ignore flag or abort the first is discarded (Topic 7.4). Data libraries de-duplicate identical in-flight requests, so you see one request.

  • ▸

    React 19's use(promise) with Suspense moves loading states to <Suspense fallback> boundaries and errors to error boundaries, but the promise must be cached (created outside render, e.g. by a library or framework), or every render creates a new request (Phase 14).

Remember this

  1. 1

    Fetching means asking a server for data over the network, usually with fetch(url), which returns a promise (an object for a value that arrives later; see /javascript promises and async/await). Showing data from a server is syncing with an outside system, so an effect is a valid place to start the request when a component appears or when its inputs (like userId) change.

  2. 2

    A request has three states the screen must show: loading (waiting), error (it failed: show a message, maybe a retry button) and success (show the data). Model them with one status value, not separate booleans that can contradict each other (Topic 6.1). The effect function itself can't be async (it must return nothing or a cleanup), so define an async function load() inside it and call it.

  3. 3

    A race condition is a bug where the result depends on which of two things finishes first. With fetching: the user picks user 1 (slow request) then user 2 (fast request). The effect starts both. User 2's response arrives first and is shown; then user 1's late response arrives and overwrites it. The screen now says user 2 is selected but shows user 1's data. It happens in real apps whenever responses take different times, which they always do.

  4. 4

    Fix 1: the ignore flag. Each run of the effect declares let ignore = false; the cleanup sets it to true. When a response arrives, it's used only if its own run's flag is still false. Because cleanup runs before the next run (Topic 7.3), every older run is marked stale as soon as userId changes, and on unmount. This is the pattern React's docs teach.

  5. 5

    Fix 2: AbortController. const controller = new AbortController(); pass controller.signal to fetch(url, { signal }); call controller.abort() in the cleanup. The browser actually cancels the request (saving bandwidth), and the promise rejects with an error whose name is "AbortError", which you ignore. Aborting while the body is still being read makes res.json() reject with the same error, so the stale data never reaches setState.

  6. 6

    Why frameworks and data libraries do it better: effects only start fetching after the component renders, so nested components cause waterfalls (parent loads, renders the child, which only then starts loading). Effects don't run on the server, there's no caching (going back re-fetches), and you must hand-write loading, error, retry and race handling. Next.js and React Router load data before rendering (Phase 13, Phase 20), and TanStack Query caches, de-duplicates and refetches (Phase 14). React 19's use with Suspense is another route. Fetching in effects is fine for small apps and is exactly what those tools do under the hood, so understanding it matters.

Explain it without notes

01

How would you fetch data in a useEffect? Cover loading, error and success.

02

Describe a race condition in data fetching and two ways to prevent it.

03

Why can't the effect function be async?

04

Why do frameworks and data libraries usually fetch better than hand-written effects?

Practice

01

Write a useEffect that loads /api/posts?page= + page with fetch, ignores stale responses, and stores the JSON in posts.

02

Rewrite the previous answer with AbortController.

Trade-offs

  • ↔

    Hand-written effect fetching has no dependencies and is easy to read, but you rebuild caching, retries and race handling yourself; past a handful of requests a data library pays for itself.

  • ↔

    AbortController saves bandwidth and server work but needs error filtering; the ignore flag is simpler but lets the request finish.

Done when you can

  • I can fetch in an effect with loading, error and success states.

  • I can explain and reproduce a fetch race condition.

  • I can fix races with an ignore flag and with AbortController.

  • I never make the effect function itself async.

  • I check res.ok for HTTP errors.

  • I can explain why frameworks and data libraries fetch better.