Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I10
21 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I10 · Part 2 · Intermediate

Data fetching patterns

Every bug in hand-rolled fetching is one of five, and they are all solved problems.

4 min read6 sections5 exercises
21 of 57

In this chapter

  • The naive version, and what is wrong with it
  • A hook that handles four of the five
  • Stale-while-revalidate
  • Mutations, and the two ways to update
  • Fetching on the server instead

and 1 more

about 4 min
06On this page
  1. The naive version, and what is wrong with it
  2. A hook that handles four of the five
  3. Stale-while-revalidate
  4. Mutations, and the two ways to update
  5. Fetching on the server instead
  6. Choosing

The naive version, and what is wrong with it

useEffect(() => {
  fetch("/api/user/" + id)
    .then((r) => r.json())
    .then(setUser);
}, [id]);

Five separate bugs, in roughly the order they will bite you.

1. No error handling — and fetch does not throw on 404

const res = await fetch(url);
if (!res.ok) throw new Error("HTTP " + res.status);   // you must check
return res.json();

fetch only rejects on a network failure. A 500 resolves happily, and res.json() then throws a parse error on the HTML error page, giving you a confusing message about an unexpected token.

2. The race condition

useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();
}, [url]);

Change id from 1 to 2 quickly and both requests are in flight. If the first is slower it resolves last and overwrites user 2 with user 1. The screen shows the wrong person and nothing looks broken. This is the bug that only appears on bad connections, which is to say on your users' connections.

3. No loading or error state

Rendering user.name before the request finishes throws. Every fetch needs at least three states, and the empty result is a fourth.

4. No cache, no deduplication

Three components asking for the same user make three requests. Navigate away and back and it refetches from scratch, showing a spinner for data you had two seconds ago.

5. The waterfall

// ✗ sequential: 300ms + 300ms
const user = await getUser(id);
const posts = await getPosts(id);

// ✓ parallel: 300ms
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);

The component version of this is worse: a parent fetches, renders, and only then does the child discover what it needs. Each level of the tree adds a round trip. Hoisting the requests, or using a router loader, collapses them.

A hook that handles four of the five

function useFetch(url) {
  const [state, setState] = useState({ status: "idle" });

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    setState({ status: "loading" });

    fetch(url, { signal: controller.signal })
      .then((r) => {
        if (!r.ok) throw new Error("HTTP " + r.status);
        return r.json();
      })
      .then((data) => setState({ status: "ready", data }))
      .catch((error) => {
        if (error.name !== "AbortError") setState({ status: "error", error });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

One status field rather than three booleans, so the impossible combinations cannot occur. What it still does not do is cache — and caching is where hand-rolling stops being worth it.

The line where you should stop writing this yourself

Caching, deduplicating in-flight requests, revalidating on focus, invalidating after a mutation, pagination, retries with backoff, optimistic updates and rollback. Each is a day of work and a source of subtle bugs. TanStack Query is roughly 12 KB and does all of it — and in a framework, the loader or Server Component does it before React is involved at all.

Stale-while-revalidate

The pattern behind every good data library, and worth understanding on its own: show the cached value immediately, refetch in the background, replace it if it changed. The screen is never blank for data you already had, and it is never wrong for long.

It changes what "loading" means. There is a first load, where there is nothing to show, and there is a revalidation, where there is stale data on screen. The first deserves a skeleton; the second deserves a subtle indicator at most. Showing a full spinner for a background refresh is the most common way this gets built wrong.

Mutations, and the two ways to update

// pessimistic: wait, then reflect the truth
await save(item);
refetch();

// optimistic: assume success, roll back if wrong
const previous = items;
setItems(items.map((i) => (i.id === id ? { ...i, done: true } : i)));
try {
  await save({ id, done: true });
} catch {
  setItems(previous);                    // the part people forget
  showError();
}

Optimistic updates make an app feel instant, and the rollback is what makes them honest. An optimistic update without one shows the user a change that never happened — and they find out when they refresh.

Fetching on the server instead

// Next.js Server Component — no effect, no loading state, no waterfall
export default async function Page({ params }) {
  const user = await getUser(params.id);
  return <Profile user={user} />;
}

Where the framework allows it, this is strictly better: the request happens next to the database, the browser gets HTML with the data already in it, and none of the five bugs above can occur because there is no client-side fetch. Client fetching then handles only what is genuinely interactive — polling, infinite scroll, anything after a user action. Server Components covers this.

Choosing

SituationUse
Framework with loaders or Server ComponentsFetch there. Nothing beats not doing it on the client.
SPA, more than a couple of screensTanStack Query or SWR
One request, one screen, a prototypeA useFetch like the one above — with the abort
GraphQLApollo or urql; the same cache ideas, schema-aware
Interview answer, one sentence

"Fetching in an effect gives you five problems: no error handling because fetch does not throw on 4xx, a race when the inputs change, no shared cache, no deduplication, and waterfalls as each level discovers what it needs. A data library solves all of them, and fetching on the server avoids them entirely."

05Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsIgnore the response that lost the raceintermediate
  • 10 testsLoad a user, without racing yourselfintermediate
  • 9 testsuseFetch: status, data and errorintermediate
  • 9 testsA paginated listintermediate
  • 5 testsA retry button after a failed fetchintermediate
All 66 for this level →
I10

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I9Previous · I9Error boundaries, portals & keys in depth
  2. I10you are here
  3. Next · I11Forms at scaleWhere hand-rolled state stops paying, and what a form library actually buys you.3 min read

01On this page

6 sections · 0%

  1. 01The naive version, and what is wrong with it
  2. 02A hook that handles four of the five
  3. 03Stale-while-revalidate
  4. 04Mutations, and the two ways to update
  5. 05Fetching on the server instead
  6. 06Choosing

[] chapters · t top