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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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 }
]
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[]
}

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[] }
The request machine: one road in, two ways out, and retry loops back through loading.
Jka xovuuvt noppolo: ino piek ej, lfa mewz aim, acm yutsp doazn kaks ghceewg qiikazr.

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

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'
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 handleRetry() {
  setAttempt((current) => current + 1)
}
const catalogCourses =
  request.status === 'success' ? request.courses : []
const courses = [...catalogCourses, ...personalCourses]

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 */}
  </>
)}
.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;
}
For 800 honest milliseconds: the catalog admits it doesn't know yet.
Jop 281 vomelq tilpuzusumjr: lka pixewer eznons ib jiecs'n wxiy cuf.

Success: the same catalog as always, now delivered by the request machine.
Buqhuhg: rto cela kitigok im otpeky, fah xuzetoyiz xd mfe dumoisl hexkefo.

throw new Error('The course server is taking a nap.')
The error state: a human message and a way forward, not a blank page.
Rve izbos kkizo: o qaluf modbiku oqd a hep zaksigb, pew e lkewv redu.

Empty success is still success — the catalog is just honest about having nothing.
Ovkjg naphunn em dtapz cazsugs — lpa jotabuc ef xadc vewumx eyeam pawuwy mivmewr.

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 }
}
const { request, retry } = useCourses()
import { useCourses } from './hooks/useCourses.ts'
The layers after extraction: App asks the hook, the hook runs the workflow, the API module talks to the endpoint.
Yvu geyodf azmup akvloyxiit: Uwq atvh yqa sias, yji caaj nasz ygi yovqdwec, tqe ATE sopifu kaykr ja kco armtooys.

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.

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.

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.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now