Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

13. Fetching Data & Writing Custom Hooks
Written by Eli Ganim

In the previous chapter, the Learning Tracker learned to remember. This chapter, it learns to wait. The catalog still materializes from an imported file — instant, infallible and nothing like production, where course data would live on a server across a slow, occasionally broken network.

You’ll replace the import with a real fetch against a mock API, and model what every networked UI must model: a request that’s either loading, failed or succeeded — never two at once. Loading indicators, an error card with retry, and the empty state all get their moment.

Then comes the chapter’s second act: extracting the whole workflow into useCourses, your first custom hook — the React pattern for packaging stateful logic into a reusable function.

Values That Arrive Later

Everything so far returned immediately. A network request can’t — the answer takes time, and JavaScript won’t sit blocked waiting for it. Instead, functions that start slow work return a Promise: an object representing a value that hasn’t arrived yet.

A Promise's short life: pending until the work ends, then fulfilled with a value or rejected with an error.
A Promise's short life: pending until the work ends, then fulfilled with a value or rejected with an error.

A Promise starts pending, then settles exactly once — fulfilled with a value, or rejected with an error. The modern way to consume one is async/await: marking a function async lets it use await, which pauses that function only until a Promise settles, then hands back the fulfilled value. If the Promise rejects instead, await throws — and try/catch, your Chapter 12 seatbelt, is how the failure becomes something you handle rather than a crash.

That’s the whole toolkit: async to enter the waiting world, await to pause politely, try/catch to survive rejection. Time to use all three.

Building the Mock API

A teaching API should be boringly reliable, so you’ll ship one with the app. Create public/api/courses.json — the public folder’s files are served as-is at the site root — with the catalog data:

[
  { "id": "react-basics", "title": "React Basics",
    "description": "Build interfaces from reusable components.",
    "category": "Frontend", "level": "Beginner",
    "durationHours": 6 },
  { "id": "css-layout", "title": "CSS Layout",
    "description": "Arrange pages with flexbox and grid.",
    "category": "Frontend", "level": "Intermediate" },
  { "id": "modern-typescript", "title": "Modern TypeScript",
    "description": "Master types for safer, clearer code.",
    "category": "Languages", "level": "Intermediate",
    "durationHours": 9 },
  { "id": "web-accessibility", "title": "Web Accessibility",
    "description": "Build interfaces everyone can use.",
    "category": "Design", "level": "Beginner",
    "durationHours": 3 }
]

The same four courses, now wearing JSON — quoted property names, no trailing commas, no comments. Next, the code that fetches it. Create a folder src/api with a file fetchCourses.ts:

import type { Course } from '../types/course.ts'

function delay(milliseconds: number): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, milliseconds)
  })
}

export async function fetchCourses(): Promise<Course[]> {
  await delay(800)
  const response = await fetch('/api/courses.json')
  if (!response.ok) {
    throw new Error(
      'The server answered status ' + response.status + '.',
    )
  }
  const data: unknown = await response.json()
  if (!Array.isArray(data)) {
    throw new Error('Unexpected response shape.')
  }
  return data as Course[]
}

Top to bottom. The delay helper builds a Promise by hand — new Promise receives a function, and calling that function’s resolve argument settles the Promise; wiring it to setTimeout makes a timer you can await. The 800-millisecond pause fakes network latency so loading states are actually visible on your fast local machine.

fetchCourses is where the toolkit performs. Each await pauses the function — for the fake latency, for the browser’s fetch, and for reading the response body as JSON. A non-OK status and a non-array body both throw, converting bad outcomes into rejections the caller must face.

The last line deserves its own paragraph: data as Course[] is a type assertion — it’s you overriding TypeScript’s judgment. It’s justifiable here because this file ships inside your own app; data from servers you don’t control deserves Chapter 12’s full field-by-field validation instead, and production teams often reach for schema-validation libraries to do it at scale. Treat as as a signed IOU, not a habit.

Save both files and check the browser: no visible change, because nothing calls fetchCourses yet — the machinery exists, unplugged.

Modeling the Request

Your UI needs to know which chapter of the request’s story it’s rendering. That’s a job for Chapter 10’s favorite move — a union of situations. In src/App.tsx, add above the App function:

type CoursesRequest =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; courses: Course[] }

Three situations, each carrying only its own data — courses exist only in success, a message only in error, and contradictions like “loading with an error message” are unrepresentable. Here’s the same type as a map:

The request machine: one road in, two ways out, and retry loops back through loading.
The request machine: one road in, two ways out, and retry loops back through loading.

Every request follows these roads and no others. Now hold the machine’s current position in state, inside App, above the personalCourses line:

const [request, setRequest] = useState<CoursesRequest>({
  status: 'loading',
})
const [attempt, setAttempt] = useState(0)

The request starts life loading — true the instant the page opens. The attempt counter looks odd until you see its trick: It exists purely to re-run the fetch effect, coming right up. Save — still no visible change, since nothing reads or feeds the new state yet.

Fetching in an Effect

A network request is outside-world work driven by the component’s lifecycle — effect territory. First, bring in the API function. In src/App.tsx, add below the reducer import:

import { fetchCourses } from './api/fetchCourses.ts'

The app’s one doorway to the network, about to be used exactly once. Now add the effect below the two state lines:

useEffect(() => {
  let ignore = false
  setRequest({ status: 'loading' })

  async function load() {
    try {
      const courses = await fetchCourses()
      if (!ignore) {
        setRequest({ status: 'success', courses })
      }
    } catch (error) {
      if (!ignore) {
        const message =
          error instanceof Error
            ? error.message
            : 'Something went wrong.'
        setRequest({ status: 'error', message })
      }
    }
  }

  load()
  return () => {
    ignore = true
  }
}, [attempt])

Three ideas share this block, so take them one at a time. The core is load: an async function that awaits the fetch inside try, storing success or — in catch — an error. Note the catch parameter’s handling: Thrown values aren’t guaranteed to be Error objects, so TypeScript types error as unknown, and instanceof narrows before touching .message, with a fallback for exotic throws.

Second, the ignore flag — this effect’s cleanup story. If the component re-renders into a new fetch (or unmounts) while an old request is still airborne, the old cleanup runs, flipping the old closure’s ignore to true, and the stale response gets silently dropped instead of overwriting fresher state. One boolean prevents a whole category of race conditions.

Third, the dependency [attempt]: The effect re-runs whenever the counter changes, which is exactly what “try again” should do. Add the handler below handleFocusSearch:

function handleRetry() {
  setAttempt((current) => current + 1)
}

Retry is just “make the effect’s dependency change” — the counter’s value never matters, only its movement. One more structural change: Courses now arrive, so the built-in list stops being an import-time constant. Replace the courses derivation line with:

const catalogCourses =
  request.status === 'success' ? request.courses : []
const courses = [...catalogCourses, ...personalCourses]

Until success, the catalog contributes nothing — and thanks to Chapter 12’s split, your personal courses and every derivation downstream keep working untouched. Delete the now-unused initialCourses import line and src/data/courses.ts with its folder — the API owns that data now. The favorites fallback in App still lists its two ids as plain strings, so nothing else changes.

Save and check the browser: For a beat under a second, the catalog area is blank — courses arrive, but nothing tells the user about the wait or a failure. That’s the last gap, and it’s a rendering job.

Rendering the Request

The JSX currently assumes courses simply exist. Teach it the three statuses: Wrap everything between </PageHeader>’s line — that is, everything inside main above the form — in status-gated blocks. Replace the JSX from the featured banner down to CourseList (keeping AddCourseForm outside) with:

{request.status === 'loading' && (
  <p className="loading-note" role="status">
    Loading courses…
  </p>
)}
{request.status === 'error' && (
  <div className="error-note" role="alert">
    <p>{request.message}</p>
    <Button label="Try again" onClick={handleRetry} />
  </div>
)}
{request.status === 'success' && (
  <>
    {/* the featured banner, MyLearning, SearchBar, focus
        button, result count and CourseList move in here,
        unchanged, indented one level deeper */}
  </>
)}

Move the existing elements into the success fragment exactly as the comment says — nothing inside them changes. The loading paragraph is a role="status" live region, announced politely; the error block is role="alert", announced immediately, and carries its retry button. TypeScript narrows in each branch: request.message only compiles inside the error check.

Style the two new states at the bottom of src/App.css:

.loading-note {
  margin: 20px 0;
  font-weight: 600;
  color: #57606a;
}

.error-note {
  margin: 20px 0;
  background: #fdecea;
  border: 1px solid #e2a6a1;
  border-radius: 8px;
  padding: 14px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.error-note p {
  margin: 0;
  font-weight: 600;
  color: #8c2321;
}

Muted text for the patient moment, a soft red panel for the bad one. Save and reload the page:

For 800 honest milliseconds: the catalog admits it doesn't know yet.
For 800 honest milliseconds: the catalog admits it doesn't know yet.

The loading note holds the stage briefly — then the catalog blinks in:

Success: the same catalog as always, now delivered by the request machine.
Success: the same catalog as always, now delivered by the request machine.

Everything works as before, from search to favorites, because success-state data flows into the same derivations. Now force the bad path. In src/api/fetchCourses.ts, add a line at the top of fetchCourses:

throw new Error('The course server is taking a nap.')

Throwing before any real work simulates a completely dead server — and since the dev server helpfully answers missing files with the HTML page, a deliberate throw is also the cleanest way to fake failure. Save:

The error state: a human message and a way forward, not a blank page.
The error state: a human message and a way forward, not a blank page.

Your message arrives via the whole pipeline — throw, rejection, catch, state, alert. Click Try again and watch loading return, then the error again; the machine loops exactly as the diagram promised. Delete the sabotage line and confirm recovery.

One outcome remains: success with nothing in it. Temporarily replace public/api/courses.json’s contents with [] and save:

Empty success is still success — the catalog is just honest about having nothing.
Empty success is still success — the catalog is just honest about having nothing.

No spinner, no error — the request succeeded, and the Chapter 9 empty-catalog message does its job. Count carefully: three union statuses, but four UI outcomes, because success renders differently with and without courses. That distinction — empty success versus failure — is one real products regularly fumble. Restore the four courses (the final materials have the file) before moving on.

Note: With the sabotage gone, open the Network tab and reload: In development, you’ll see two requests for courses.json. That’s Strict Mode’s effect rehearsal from Chapter 12 — setup, cleanup, setup — and your ignore flag is what keeps the abandoned first request from writing state. Production sends one. This is a development X-ray, not a bug to fix.

Extracting useCourses

Step back and look at App: request modeling, fetch orchestration, retry plumbing — none of it is about the page. React’s tool for relocating stateful logic is the custom hook: a plain function whose name starts with use and which may call other hooks. Create a folder src/hooks with a file useCourses.ts:

import { useEffect, useState } from 'react'
import { fetchCourses } from '../api/fetchCourses.ts'
import type { Course } from '../types/course.ts'

export type CoursesRequest =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; courses: Course[] }

export function useCourses() {
  const [request, setRequest] = useState<CoursesRequest>({
    status: 'loading',
  })
  const [attempt, setAttempt] = useState(0)

  useEffect(() => {
    let ignore = false
    setRequest({ status: 'loading' })

    async function load() {
      try {
        const courses = await fetchCourses()
        if (!ignore) {
          setRequest({ status: 'success', courses })
        }
      } catch (error) {
        if (!ignore) {
          const message =
            error instanceof Error
              ? error.message
              : 'Something went wrong.'
          setRequest({ status: 'error', message })
        }
      }
    }

    load()
    return () => {
      ignore = true
    }
  }, [attempt])

  function retry() {
    setAttempt((current) => current + 1)
  }

  return { request, retry }
}

Familiar code in a new home: the union, both pieces of state, the entire effect and a retry function, returned as an object with exactly what callers need — the current request and one action. Everything else is private.

Now put App on a diet. Delete from src/App.tsx: the CoursesRequest type, the request and attempt state lines, the whole fetch effect, handleRetry and the fetchCourses import. In their place, one line at the top of the component:

const { request, retry } = useCourses()

One line asks for everything the page needs: the current request and the retry lever, destructured from the hook’s return object. And one import, below the reducer import:

import { useCourses } from './hooks/useCourses.ts'

Note that fetchCourses disappears from App‘s imports — the page no longer knows the network exists. Update the error block’s button to onClick={retry}. Save — the app behaves identically, loading and all:

The layers after extraction: App asks the hook, the hook runs the workflow, the API module talks to the endpoint.
The layers after extraction: App asks the hook, the hook runs the workflow, the API module talks to the endpoint.

The page component now reads like its old self — “give me the request and a retry lever” — while the networking lives where it can be reused and, in Chapter 16, tested.

Note: Hooks follow two rules, and custom hooks are why they’re phrased carefully. Call hooks only at the top level — never inside conditions, loops or handlers — so React can match state to calls by order, every render. And call hooks only from components or other hooks — the use prefix is how tooling knows the rules apply. Also know: Every caller of useCourses gets its own independent state, just like every <CourseCard /> got its own useState — hooks share logic, never data.

One honest caveat closes the topic: For apps with heavy server interaction — caching, background refresh, mutations — production teams usually adopt a dedicated server-state library rather than hand-rolling every hook. Your useCourses is the real foundation those tools build on, and for this app it’s exactly enough.

Challenge: Add a Manual Refresh

The catalog fetches once per visit, but data changes — give users a Refresh catalog ghost button, right below the result count, that re-runs the fetch on demand.

A few hints:

  • The hook already returns everything you need; look at what the error state’s button uses.
  • Refreshing should visibly pass through loading — convince yourself the existing effect guarantees it before checking in the browser.
  • Ghost variant, since it’s a secondary action.

You’ll find a complete solution in the challenge folder of this chapter’s materials.

Key Points

  • A Promise is a value that hasn’t arrived: pending, then fulfilled or rejected — and await pauses only the async function that calls it.
  • Rejections become exceptions at the await; try/catch turns them into error UI instead of crashes, and caught values are unknown until instanceof narrows them.
  • Model requests as a union of situations — loading, error-with-message, success-with-data — so contradictory screens can’t exist.
  • Fetch in an effect; use an ignore flag in cleanup so stale responses from abandoned requests never overwrite fresh state.
  • Retry is a dependency trick: Bump a counter the effect depends on.
  • Distinguish empty success from failure — an empty catalog is a result, not an error.
  • Strict Mode’s rehearsal sends two development requests on mount; correct cleanup makes that harmless, and production sends one.
  • A custom hook is a use-prefixed function that calls hooks, packaging stateful logic for reuse — each caller gets independent state.
  • Follow the Rules of Hooks: top level only, from components or hooks only.

Where to Go From Here?

The Learning Tracker now earns its data the way real apps do — asynchronously, fallibly and honestly, with every state accounted for and the machinery packed into a hook you could drop into any project.

Next, a different kind of plumbing problem: Some values — like a user’s theme preference — are needed everywhere, and threading them through six layers of props turns components into couriers. Chapter 14 introduces context, React’s tool for genuinely app-wide values, and uses it to give the Learning Tracker something it’s overdue: a dark mode.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.