On this page:
Learning Objectives
1. Motivation:   The API Has No Face
1.1 Why not just plain Java  Script and the DOM?
1.2 Why Type  Script?
1.3 Where this sits in the stack
2. Running Example:   Terp  Tasks Gets a Face
A Minimal Example:   “Hello, React”
Step 1. Check that Node.js is installed
Step 2. Create the project
Step 3. Replace App.tsx
Step 4. Start the dev server
Step 5. Watch hot reloading, and add state
3. Components and Props
3.1 Components
3.2 Props
4. State:   use  State
4.1 The hook
5. Effects:   use  Effect
6. Type  Script with React
6.1 Interfaces that mirror the backend
6.2 Typed props and state
7. Talking to Fast  API
7.0 The other side of the wire
7.1 async/await and Promises
7.2 fetch vs. Axios
7.3 One typed client module
8. The Three States of Every Network Screen
9. The Complete Integration Example
10. Type  Script as a CI Gate:   tsc no  Emit
11. The Complete Project
11.1 Repository structure
11.2 Testing a React app
11.3 Build & run
Practice Exercises
Basic
Intermediate
Advanced
Summary
Key takeaways
Terminology
Common mistakes
Connections
Appendix A. Extracting a Custom Hook:   use  Tasks
Appendix B. CORS:   The Full-Stack Gotcha You Will Hit
9.1

8 React/TypeScript Frontend & Full-Stack IntegrationπŸ”—

    Learning Objectives

    1. Motivation: The API Has No Face

      1.1 Why not just plain JavaScript and the DOM?

      1.2 Why TypeScript?

      1.3 Where this sits in the stack

    2. Running Example: TerpTasks Gets a Face

    A Minimal Example: “Hello, React”

      Step 1. Check that Node.js is installed

      Step 2. Create the project

      Step 3. Replace App.tsx

      Step 4. Start the dev server

      Step 5. Watch hot reloading, and add state

    3. Components and Props

      3.1 Components

      3.2 Props

    4. State: useState

      4.1 The hook

    5. Effects: useEffect

    6. TypeScript with React

      6.1 Interfaces that mirror the backend

      6.2 Typed props and state

    7. Talking to FastAPI

      7.0 The other side of the wire

      7.1 async/await and Promises

      7.2 fetch vs. Axios

      7.3 One typed client module

    8. The Three States of Every Network Screen

    9. The Complete Integration Example

    10. TypeScript as a CI Gate: tsc noEmit

    11. The Complete Project

      11.1 Repository structure

      11.2 Testing a React app

      11.3 Build & run

    Practice Exercises

      Basic

      Intermediate

      Advanced

    Summary

      Key takeaways

      Terminology

      Common mistakes

      Connections

    Appendix A. Extracting a Custom Hook: useTasks

    Appendix B. CORS: The Full-Stack Gotcha You Will Hit

The companion project for this lecture is 08-terptasks-web in the course exercises repository — a complete, type-checked, tested Vite + React + TypeScript app that talks to the TerpTasks API you already built:

https://github.com/software-dev-genai/exercises/tree/main/08-terptasks-web

Learning ObjectivesπŸ”—

By the end of this lecture, you should be able to:

  1. Build a React UI from components write function components, pass data down with props, and explain why React re-renders in terms of the “UI is a function of state” model.

  2. Manage state and effects with hooks use useState for values that change and useEffect for talking to the outside world, and state the rules that keep hooks correct.

  3. Type a React app end to end write interfaces that mirror your FastAPI Pydantic models, type props and state, and explain what a wrong type costs you at compile time instead of at 2am.

  4. Connect a frontend to FastAPI call the API with fetch (and know the Axios trade-off), use async/await, and correctly render the three states every network screen has: loading, error, data.

  5. Diagnose the full-stack seams thread VITE_API_URL through config, and trace one button click from the browser to the database and back.

  6. Gate merges on types run tsc noEmit locally and in CI, and explain why a frontend type gate catches an entire class of integration bugs that tests would miss.

Two further objectives are served by the appendices rather than the lecture itself, because you will need both for the project even though neither fits in 75 minutes:

  • Extract a custom hook (Appendix A) — move server state into a reusable useTasks hook and explain why that mirrors the backend’s layered architecture.

  • Fix a CORS error (Appendix B) — recognize the failure on sight and know that it is resolved on the server.

1. Motivation: The API Has No FaceπŸ”—

You spent the FastAPI lecture building TerpTasks a clean CRUD API for course assignments. You can talk to it with curl, with the /docs page, with a .http file. But a student who wants to check off “finish the React reading” is not going to open a terminal and type a PATCH request. An API is a contract, not a product. The product is the thing with buttons.

This lecture builds that thing: the frontend the code that runs in the user’s browser, draws the buttons, and turns clicks into the HTTP requests your backend already understands. And it builds it the way real teams do: in React (the dominant way to build UIs) with TypeScript (so the browser code and the server contract can’t silently drift apart).

1.1 Why not just plain JavaScript and the DOM?πŸ”—

You could write document.createElement, wire up addEventListener, and manually update the page every time a task changes. People did this for a decade, and it produced a specific kind of bug: the screen and the data disagreeing. You delete a task from the array but forget to remove its <li>; now the UI is lying. The core problem is manual synchronization every piece of state has to be hand-copied into the DOM, and every copy is a chance to get it wrong.

React’s one big idea kills that class of bug:

The UI is a function of state. You describe what the screen should look like for a given state, and React figures out the DOM operations to make it so. You never touch the DOM; you change state and re-describe.

Intuition — the thermostat vs. the furnace. Old-style DOM code is a furnace: you manually turn heat up and down and hope the room lands at the right temperature. React is a thermostat: you declare “I want 70°” and the system drives the actuators. You stopped managing steps; you started declaring outcomes.

1.2 Why TypeScript?πŸ”—

Your backend already validates data — Pydantic rejects a task with no title. But the browser is a different program, in a different language, that only learns the shape of a Task at runtime, by receiving JSON. Nothing stops you from writing task.titel (typo) or treating due as a Date when the server sends a string. In plain JavaScript, that’s a blank screen and a console error a user hits in production.

TypeScript moves that error to compile time. You write down the shape of a Task once, as an interface, and every misuse — the typo, the wrong type, the field you forgot the server can send null for — becomes a red squiggle in your editor and a failed CI check. This is the same shift the eval lecture made (catch regressions before shipping) and the testing lecture made (catch bugs before users), applied to the frontend/backend seam, which is exactly where full-stack bugs concentrate.

1.3 Where this sits in the stackπŸ”—

   BROWSER (this lecture)                    SERVER (FastAPI lecture)

┌───────────────────────────┐   HTTP/JSON  ┌──────────────────────────┐

  React components (UI)     │◄────────────►│  Routes (main.py)        

  useTasks hook (state)        fetch/       Pydantic models         

  api.ts (typed client)        axios        Storage / DB            

  types.ts  ── mirrors ─────┼──────────────┤  models.py  (the truth)  

└───────────────────────────┘              └──────────────────────────┘

The dashed line is the theme of the whole lecture: types.ts on the browser side is a mirror of models.py on the server side. Keeping that mirror honest — by hand today, with codegen in industry — is what “full-stack integration” actually means.

2. Running Example: TerpTasks Gets a FaceπŸ”—

We build one app across the whole lecture: a web UI for TerpTasks. It will:

  • List tasks (GET /tasks) when the page loads.

  • Create a task from a form (POST /tasks).

  • Toggle a task done via a checkbox (PATCH /tasks/{id}).

  • Delete a task (DELETE /tasks/{id}).

Every one of those maps to a route you already wrote. Here is the target, in ASCII:

┌─────────────────────────────────────────────┐

│ TerpTasks                                     

│ ┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │

│ │ Title…  │ │ Course │ │medium ▾│ │Add task│ │   ← NewTaskForm

│ └─────────┘ └────────┘ └────────┘ └────────┘ │

│ ▏☐  Finish FastAPI reading — CMSC389A          ← TaskItem (high: red bar)

│ ▏☑  Read React notes — CMSC389A                ← TaskItem (done: struck through)

└─────────────────────────────────────────────┘

We will build it bottom-up: first the pieces (components, props), then the state (hooks), then the network (the typed client), then the seams (the type gate). The full, runnable project is in §11; every snippet below is lifted from it.

Keep this running scenario in mind — a student clicks the checkbox on “Finish FastAPI reading.” By §9 you’ll be able to trace that click from the DOM event, through React state, across fetch, into a PATCH on the server, and back into a re-render — and you’ll know every place it can fail.

A Minimal Example: “Hello, React”πŸ”—

Before we build TerpTasks, get a React + TypeScript app running on your own machine. The fastest path is Vite, a build tool that gives you a dev server, TypeScript compilation, and hot reloading with no configuration.

Step 1. Check that Node.js is installedπŸ”—

In your terminal:

node --version
npm --version

If both commands print a version, you are ready. If not, install Node.js (the LTS release) first.

Step 2. Create the projectπŸ”—

npm create vite@latest hello-react -- --template react-ts
cd hello-react
npm install

The react-ts template is the React + TypeScript scaffold; npm install downloads the dependencies into node_modules/.

Step 3. Replace App.tsxπŸ”—

Open the app’s single component:

hello-react/

└── src/

    └── App.tsx

Replace its contents with:

function App() {
  return <h1>Hello, World!</h1>;
}

export default App;

That is a complete React component: a function that returns a description of UI. We unpack exactly what that means in §3.

Step 4. Start the dev serverπŸ”—

npm run dev

You will see something similar to:

Local:   http://localhost:5173/

Open that address in your browser. You should see Hello, World!

Step 5. Watch hot reloading, and add stateπŸ”—

When you save a change to App.tsx, Vite notices and updates the browser automatically — no manual refresh. Leave the server running and replace App.tsx with a counter:

import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </>
  );
}

export default App;

Click the button and watch the heading change. You never wrote code to update that <h1>: you changed state, and React re-rendered the UI to match. That is the whole model — UI is a function of state and §4 makes useState precise.

Note. The useState import from react at the top is required; forgetting it is the first error most students hit. The <>...</> is a fragment, which lets a component return two sibling elements without wrapping them in an extra <div> (see §3.1).

3. Components and PropsπŸ”—

3.1 ComponentsπŸ”—

A React component is a JavaScript/TypeScript function that takes a single props object and returns a description of UI (a React element tree, written in JSX). React calls your function and reconciles its output with the real DOM.

Plain English. A component is a reusable custom HTML tag you define with a function. <TaskItem task={...} /> looks like an element but is really a function call.

JSX is the HTML-looking syntax inside the function. It is not HTML and not a string — it’s syntax sugar that compiles to function calls. A few rules bite everyone once:

  • Return one root element (wrap siblings in a parent or a <>…</> fragment).

  • class becomes className (because class is a reserved word in JS).

  • { } drops you back into JavaScript: {task.title}, {done ? "✓" : ""}.

Common misconception: “JSX is a template string.” No — {task.title} is a real expression evaluated by JS, type-checked by TypeScript, with full editor autocomplete. A template string is opaque text; JSX is code.

The smallest possible component. Before the real thing, here is a component with no props at all — it is just a function that returns JSX:

function Greeting() {
  return <h1>Hello!</h1>;
}

Components compose: you use one inside another exactly as you’d use a built-in tag, and that is how a page gets built out of parts.

function App() {
  return (
    <>
      <Greeting />
      <TaskList />
    </>
  );
}

That <>…</> is the fragment from the rules above: App returns one root, and the fragment groups two siblings without adding a pointless <div> to the DOM.

Worked example. Now the real one — our smallest useful component, one task row:

export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
  return (
    <li className={`task priority-${task.priority}`}>
      <label>
        <input type="checkbox" checked={task.done} onChange={() => onToggle(task)} />
        <span className={task.done ? "done" : ""}>
          <strong>{task.title}</strong> β€” {task.course}
        </span>
      </label>
      <button type="button" onClick={() => onDelete(task.id)}>βœ•</button>
    </li>
  );
}

Read it as: “given a task and two callbacks, here is the <li> that represents it.” Notice there is no DOM manipulation no appendChild, no .innerHTML. You describe; React renders.

3.2 PropsπŸ”—

Props (properties) are the single input argument to a component: a read-only object passed by the parent. Data flows down, from parent to child.

Plain English. Props are function arguments for components. <TaskItem task={t} /> passes t as props.task.

Intuition. Props are the “wiring diagram” of your UI. The parent decides what data and which callbacks each child gets; the child can’t reach up and grab anything else. This one-way flow is why React apps stay debuggable at scale: to understand a component, you only read its props, not the whole program.

Common misconception: “A child can change its props.” Props are read-only. A child that wants to change something calls a callback the parent passed down (that’s what onToggle/onDelete are). The parent owns the data; the child requests changes. This “data down, events up” pattern is the backbone of React.

Practical implication. Components split cleanly into two kinds: presentational (just render props — TaskItem, NewTaskForm) and container (own state and logic — App, and the useTasks hook). Keeping rows dumb makes them trivial to test and reuse.

4. State: useStateπŸ”—

Props come from above and don’t change on their own. But a form’s text, a “submitting…” flag, the list of tasks — these change inside a component in response to events. That’s state.

4.1 The hookπŸ”—

useState<T>(initial) returns a tuple [value, setValue]. value is the current state for this render; setValue(next) asks React to re-render this component with new state.

Plain English. useState gives a component a memory cell that survives re-renders, plus a setter. Calling the setter re-runs the component function with the new value.

const [title, setTitle] = useState("");        // T inferred as string
const [priority, setPriority] = useState<Priority>("medium");  // T given explicitly

Aside: that <T> is a generic type. This is the first place the lecture needs one, so let’s name it. A generic is a type with a hole in it, filled in at the point of use — useState isn’t “state of one particular type,” it’s “state of whatever type you hand it.” Writing useState<Priority>("medium") says this cell will always hold a Priority, and the compiler then knows that priority is a Priority and that setPriority("urgent") is an error. You will see the same angle-bracket notation on useState<Task[]>([]) in §9 (“an array of Task”) and on our own handle<T> helper in §7.3.

When can you leave it off? When TypeScript can infer it from the initial value: useState("") obviously holds a string. You supply it explicitly when the initial value is less informative than the truth — useState([]) would infer never[], which is why the task list needs useState<Task[]>([]) to be usable.

Intuition — the render loop. A component function runs, returns JSX, React paints. When you call setTitle("Read"), React schedules another run of the same function; this time useState hands back "Read", the JSX differs, React updates only what changed. State is the input to each render; the setter is how you request the next render.

Common misconception #1: “setState updates the variable immediately.” It does not. title is a const for the duration of this render. setTitle("x") schedules a future render where title will be "x". Reading title on the very next line still gives the old value. State updates are requests, not assignments.

Common misconception #2: “I can just push to the array in state.” Never mutate state in place:

tasks.push(newTask);          // WRONG: React sees the same array reference, skips re-render
setTasks([...tasks, newTask]); // new array β†’ React re-renders

React decides whether to re-render by comparing references. Mutating keeps the same reference, so your change is invisible. Always produce a new array/object.

Worked example — a controlled input. In NewTaskForm, the text box’s value is React state:

<input value={title} onChange={(e) => setTitle(e.target.value)} />

Every keystroke fires onChangesetTitle → re-render → the input shows the new title. React state is the single source of truth; the DOM input just reflects it. This is a controlled component, and it’s why clearing the form after submit is just setTitle("").

5. Effects: useEffectπŸ”—

useState handles values that change. But “fetch the task list when the page first appears” isn’t a value change — it’s a side effect: reaching outside React to the network. That’s useEffect.

useEffect(fn, deps) runs fn after the component renders, and re-runs it whenever any value in the deps array changes since the last render. Returning a function from fn registers cleanup, run before the next effect and on unmount.

Plain English. “After you render, do this out-of-band thing — but only re-do it when these specific inputs change.”

Intuition. Rendering must be pure (no network calls, no timers — just compute JSX from props/state). Effects are the sanctioned escape hatch for the impure stuff: fetching data, subscriptions, timers, manual DOM pokes.

The dependency array is the whole game:

deps

When the effect runs

[]

Once, after the first render (mount). Our “load tasks on open.”

[query]

After mount, and every render where query changed.

(omitted)

After every render — almost always a bug (can loop forever).

Common misconception: “useEffect runs during render.” It runs after the render is committed to the screen, asynchronously. So the first paint shows the initial state (an empty list / a spinner), and the effect then fetches and calls setState, causing a second render with data. That two-step is exactly why the loading state in §8 exists.

Worked example — load once on mount:

useEffect(() => {
  void loadTasks();   // `void` says "I intentionally don't await this Promise"
}, []);               // empty deps β†’ runs once, after the first render

We’ll see the full loadTasks in §9. Note a subtlety we’ll pay off there: the effect callback itself can’t be async (an async function returns a Promise, but React expects an effect to return cleanup or nothing), so we declare an async helper and call it from inside a plain function.

6. TypeScript with ReactπŸ”—

Now the type layer — the reason this app won’t rot when the backend changes.

6.1 Interfaces that mirror the backendπŸ”—

An interface (or type) describes the shape of an object: which fields exist and what type each is. TypeScript checks every use against it and erases it at build time (types don’t exist at runtime).

The single most important file in the app is src/types.ts, and it is a hand-mirror of the server’s app/models.py:

// Mirrors the server's Priority string enum.
export type Priority = "low" | "medium" | "high";

// Mirrors the `Task` Pydantic model (what the server returns).
export interface Task {
  id: number;
  title: string;
  course: string;
  due: string | null;   // JSON has no date type: a `date` arrives as an ISO string
  priority: Priority;
  done: boolean;
}

// Mirrors `TaskCreate` (what we POST). No id/done β€” the server owns those.
export interface TaskCreate {
  title: string;
  course: string;
  due?: string | null;
  priority: Priority;
}

Line up models.py and types.ts side by side and you can see the contract. Three translations are worth calling out, because they’re where full-stack bugs live:

  • datestring | null. JSON has no date type. FastAPI serializes a Python date to "2026-03-01". If you typed due: Date, you’d be lying — new Date(task.due) would be your job, and TypeScript would (correctly) let you forget it.

  • Pydantic Priority enum → a TS union of string literals. "low" | "medium" | "high" means the compiler rejects priority: "urgent" before it ever reaches the server’s validator.

  • Optional vs. nullable. due?: string | null says two different things at once: the field may be absent from the object we send (?), and its value may be null. Both are true for TaskCreate.due.

Common misconception: “TypeScript validates the API response at runtime.” It does not. Types are erased before the code runs. const t = await res.json() as Task is a promise you make to the compiler, not a check. If the server actually sends { titel: ... }, TypeScript is none the wiser at runtime — it trusts your cast. (Runtime validation needs a library like Zod; that’s the intermediate exercise.) What types buy you is that everywhere else in your code, using t.title is checked, and t.titel is a compile error.

6.2 Typed props and stateπŸ”—

Props get an interface; state gets a type argument when inference isn’t enough:

interface TaskItemProps {
  task: Task;
  onToggle: (task: Task) => void;   // a callback: takes a Task, returns nothing
  onDelete: (id: number) => void;
}

export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) { … }

Now the compiler enforces the wiring diagram: render <TaskItem /> without an onDelete, or pass onDelete={() => …} that expects a string, and it’s a red error at the call site, in the parent, before you ever run the app. The bug can’t reach the browser.

Worked example — the error TypeScript catches for free. Suppose the backend team renames done to completed in models.py. You update types.ts to match. Instantly, tsc flags every place that read task.done TaskItem’s checkbox, useTasks’ toggle, the .done CSS class. You get a to-do list of exactly what to fix, generated by the compiler. In plain JS, you’d find those one blank checkbox at a time, in production.

7. Talking to FastAPIπŸ”—

7.0 The other side of the wireπŸ”—

Before writing a line of browser code, put the server back on screen. This is the TerpTasks route from the FastAPI lecture — the thing our fetch is about to call:

# app/main.py (server side)
@app.get("/tasks", response_model=list[Task])
def list_tasks(course: str | None = None):
    return store.list(course=course)

and this is what it puts on the wire:

[
  {"id": 1, "title": "Finish FastAPI reading", "course": "CMSC389A",
   "due": "2026-03-01", "priority": "high", "done": false}
]

Keep both in view for the rest of the section. Everything that follows — the fetch call, the Task interface, the handle<T> helper — exists to move that JSON across the language boundary without either side lying about its shape.

7.1 async/await and PromisesπŸ”—

Network calls take time, so they’re asynchronous: the function returns a Promise immediately and the result arrives later. async/await lets you write that in a straight line:

async function load() {
  const res = await fetch(url);   // pause here until the response arrives
  const data = await res.json();  // pause again while the body is parsed
  return data;                    // the Promise this function returns resolves to `data`
}

await doesn’t block the browser — it yields, lets the UI stay responsive, and resumes when the Promise settles. Anything that can await can also throw, so async functions pair with try/catch.

7.2 fetch vs. AxiosπŸ”—

Two common ways to make the call. Here is the same create request both ways:

// fetch β€” built into the browser, zero dependencies
const res = await fetch(`${BASE}/tasks`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(data),
});
if (!res.ok) throw new ApiError(res.status, "…");  // you MUST check ok yourself
const task: Task = await res.json();               // and parse the body yourself

// axios β€” a small library
const { data: task } = await axios.post<Task>(`${BASE}/tasks`, data);
// auto-JSON in and out; throws on 4xx/5xx automatically

fetch

Axios

Install

Built in

npm install axios

JSON

Manual JSON.stringify / res.json()

Automatic both ways

Errors on 404/500

Resolves normally (ok === false) — easy to forget

Rejects the Promise

Timeouts, interceptors

Roll your own

Built in

Bundle size

0

~13 kB

The one fetch gotcha that bites everyone: fetch only rejects on a network failure (server unreachable). A 404 or 500 is a successful fetch of an error response — res.ok is false, but the Promise resolves. If you forget to check res.ok, you’ll happily res.json() an error body and treat it as data. Axios rejects on 4xx/5xx, which is why many teams prefer it. Our project uses fetch and centralizes the res.ok check in one place so no call site can forget — which is the next point.

7.3 One typed client moduleπŸ”—

Components should never call fetch directly. All network code lives in src/api.ts, exactly like the backend keeps storage in one layer. Here is the heart of it:

const BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://127.0.0.1:8000";

export class ApiError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
    this.name = "ApiError";
  }
}

// Turn a Response into typed data, or throw. The `ok` check lives HERE, once.
async function handle<T>(res: Response): Promise<T> {
  if (!res.ok) {
    let detail = res.statusText;
    try {
      const body = (await res.json()) as { detail?: string };  // FastAPI: {"detail": "..."}
      if (body.detail) detail = body.detail;
    } catch { /* non-JSON error body */ }
    throw new ApiError(res.status, detail);
  }
  if (res.status === 204) return undefined as T;   // DELETE has no body
  return (await res.json()) as T;
}

export async function listTasks(): Promise<Task[]> {
  return handle<Task[]>(await fetch(`${BASE_URL}/tasks`));
}

export async function updateTask(id: number, changes: TaskUpdate): Promise<Task> {
  const res = await fetch(`${BASE_URL}/tasks/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(changes),
  });
  return handle<Task>(res);
}

Three design decisions to notice:

  • The generic handle<T> parses success, converts a FastAPI {"detail": …} error into a typed ApiError that carries the status code (so a component can tell 404 from 500), and knows that 204 No Content has no body to parse. Every function funnels through it — the res.ok gotcha is handled once.

  • BASE_URL from import.meta.env.VITE_API_URL. The backend’s address is configuration, not a hard-coded string. Vite injects any VITE_-prefixed env var at build time; the ?? "http://127.0.0.1:8000" is the local-dev default. Ship to production by setting VITE_API_URL, no code change.

  • Return types are Promise<Task>, Promise<Task[]>, Promise<void>. The typed boundary starts here: everything downstream knows what it’s getting.

8. The Three States of Every Network ScreenπŸ”—

The moment a screen depends on the network, it has three possible states, and beginners routinely render only one:

fetch starts

     

┌────▼────┐   success   ┌──────────┐

│ LOADING │────────────►│   DATA     (which may be empty!)

└────┬────┘             └──────────┘

     │ failure

┌────▼────┐

  ERROR    (with a Retry)

└─────────┘

  • Loading the request is in flight. Show a spinner/skeleton, not a blank screen (which looks broken).

  • Error the request failed (server down → fetch rejects; 404/500 → ApiError). Show the message and a way to recover.

  • Data success. Beware the empty sub-case: zero tasks is data, not an error, and deserves “No tasks yet,” not a blank void.

Common misconception: “I’ll just render the list.” That assumes the happy path is instant and infallible. It never is. The most common beginner bug is a flash of “undefined” or a crash on tasks.map because tasks was briefly undefined while loading. Model all three states explicitly and that bug is impossible.

Worked example — the readable form. The clearest way to write this is early returns: handle each unhappy state and get it out of the way, so the interesting render is last and un-nested.

if (loading) return <p>Loading tasks…</p>;

if (error) {
  return (
    <div className="error">
      <p>Couldn’t load tasks: {error}</p>
      <button onClick={() => void loadTasks()}>Retry</button>
    </div>
  );
}

if (tasks.length === 0) return <p>No tasks yet. Add one above.</p>;

return <ul>{tasks.map((t) => <TaskItem key={t.id} task={t} …/>)}</ul>;

Read it top to bottom as a checklist: still loading? show a spinner. Blew up? show the message and a way out. Succeeded but empty? say so. Otherwise — and only otherwise — draw the list.

The same thing, inline. When the states are rendered in the middle of a larger JSX tree (under a heading and a form, say), you can’t early-return, so the same four branches become a nested ternary:

{loading ? (
  <p>Loading tasks…</p>
) : error ? (
  <div className="error">
    <p>Couldn’t load tasks: {error}</p>
    <button onClick={() => void loadTasks()}>Retry</button>
  </div>
) : tasks.length === 0 ? (
  <p>No tasks yet. Add one above.</p>
) : (
  <ul>{tasks.map((t) => <TaskItem key={t.id} task={t} …/>)}</ul>
)}

This is what the shipped project uses, and it is worth being able to read — but reach for early returns first; they stay legible when a fifth state shows up.

That key={t.id} is not optional: React uses key to match elements across renders so it can move/update rather than rebuild rows. Use a stable, unique id (the task’s id), never the array index.

9. The Complete Integration ExampleπŸ”—

Every piece is now on the table: components and props (§3), state (§4), effects (§5), the Task interface (§6), the typed client (§7), and the three states (§8). This section assembles them into one component that talks to FastAPI the whole lecture in fifty lines.

export function TaskListApp() {
  // ── state: one cell per thing that can change ────────────────────
  const [tasks, setTasks] = useState<Task[]>([]);      // Β§4 + generics
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // ── the effect: reach outside React, once, on mount ──────────────
  async function loadTasks() {
    setLoading(true);
    setError(null);
    try {
      setTasks(await listTasks());        // Β§7 typed client β†’ Promise<Task[]>
    } catch (err) {
      setError(messageFor(err));          // Β§8 error state
    } finally {
      setLoading(false);                  // runs on success AND on failure
    }
  }

  useEffect(() => {
    void loadTasks();                     // Β§5 async helper, plain callback
  }, []);

  // ── an event handler: data down, events up ───────────────────────
  async function handleToggle(task: Task) {
    const updated = await updateTask(task.id, { done: !task.done });
    setTasks((prev) =>                    // Β§4 new array, never a mutation
      prev.map((t) => (t.id === updated.id ? updated : t)));
  }

  // ── render: all three states, Β§8 ─────────────────────────────────
  if (loading) return <p>Loading tasks…</p>;

  if (error) {
    return (
      <div className="error">
        <p>Couldn’t load tasks: {error}</p>
        <button onClick={() => void loadTasks()}>Retry</button>
      </div>
    );
  }

  if (tasks.length === 0) return <p>No tasks yet. Add one above.</p>;

  return (
    <ul>
      {tasks.map((t) => (
        <TaskItem key={t.id} task={t} onToggle={handleToggle} … />
      ))}
    </ul>
  );
}

The finally block is the unsung hero: loading must end whether the request succeeded or threw, and finally is the only place that’s guaranteed. Delete it and one failed request leaves a spinner on screen forever.

Now trace the running scenario. A student clicks the checkbox on “Finish FastAPI reading”:

1. DOM 'change' event fires on the <input>.

2. TaskItem's onChange calls onToggle(task)  ── event bubbles UP via props ──►

3. TaskListApp's handleToggle(task) runs

4. await updateTask(1, { done: true })

5. api.ts: fetch PATCH /tasks/1  ──HTTP──►  FastAPI update_task  ──►  store.update

6. Server returns 200 + the updated Task JSON

7. handle<Task> parses it → handleToggle gets `updated`

8. setTasks(prev => prev.map(...))  → React re-renders

9. TaskItem receives task.done === true → checkbox checked, text struck through

Nine steps, four files, one language boundary — and TypeScript checked the shape of the data at steps 4, 7, and 8. That is full-stack integration.

Read it as the round trip the whole course has been building toward:

FastAPI route    HTTP/JSON    fetch()    handle<Task[]>    Task[]

                                                                 

        HTML    re-render    React state  ←────────────────  setTasks

Where this goes next. Notice that TaskListApp now holds two unrelated jobs: how to talk to the server and how to draw the screen. Once a second component needs the same task list, that becomes a problem — and the fix is to extract the state into a custom hook, useTasks. That is exactly what the shipped project does, and it is written up in Appendix A.

10. TypeScript as a CI Gate: tsc noEmitπŸ”—

The testing and eval lectures drilled one habit: nothing merges if the check is red. The frontend adds a check those don’t have — the type check and it catches an entire class of bug the tests can’t.

tsc noEmit runs the TypeScript compiler over the whole project, reports every type error, and emits no output files (Vite does the actual building). Its exit code is nonzero if any type error exists.

Why a separate gate from tests? Tests check the paths you thought to exercise. The type checker checks every line against the contract, including paths no test covers. When the backend changes Task, no test may fail — but tsc flags every stale field access. It’s a different kind of coverage: total, structural, and free.

// package.json
"scripts": {
  "typecheck": "tsc --noEmit",
  "build": "tsc --noEmit && vite build",   // build refuses to ship type errors
  "test": "vitest run"
}

And the gate in CI (.github/workflows/frontend.yml), the frontend twin of the eval gate:

- name: Type check
  run: npm run typecheck        # exits nonzero on the first type error β†’ red check
- name: Test
  run: npm test

This is not hypothetical — it happened while writing this project. A unit test indexed a mock’s call arguments (spy.mock.calls[0][0]) without typing the mock’s parameters, so TypeScript inferred the argument tuple as empty ([]) and the index as undefined. The tests would have passed; tsc noEmit failed with TS2493: Tuple type [] has no element at index 0. The gate caught a latent bug the test suite was blind to. That is the whole argument for the type gate in one incident.

Common misconception: “strict mode is pedantic; I’ll turn it off.” strict: true (in tsconfig.json) is what turns on null checking — the thing that forces you to handle due: string | null and a possibly-missing #root element. Turning it off buys you silence now and undefined is not an object in production later. Keep it on; it’s the feature, not the friction.

11. The Complete ProjectπŸ”—

Everything above is implemented, type-checked, and tested in 08-terptasks-web in the course exercises repository (https://github.com/software-dev-genai/exercises/tree/main/08-terptasks-web). It builds clean and the suite is green (tsc noEmit passes; 8 tests pass).

11.1 Repository structureπŸ”—

08-terptasks-web/

├── index.html                # the page React mounts into

├── package.json              # scripts + deps (react, axios, vite, vitest, typescript)

├── tsconfig.json             # strict mode ON

├── vite.config.ts            # Vite + Vitest (jsdom) config

├── .env.example              # VITE_API_URL

├── .github/workflows/

   └── frontend.yml          # CI: npm ci → typecheck → test

└── src/

    ├── types.ts              # Task / TaskCreate / TaskUpdate — mirror models.py   (§6)

    ├── vite-env.d.ts         # types import.meta.env.VITE_API_URL

    ├── api.ts                # typed fetch client + ApiError                        (§7)

    ├── api.test.ts           # unit tests, stubbed fetch                            (§10)

    ├── hooks/useTasks.ts     # server state + loading + error + actions       (Appendix A)

    ├── components/

       ├── TaskItem.tsx      # one row (presentational)                             (§3)

       └── NewTaskForm.tsx   # controlled create form                               (§4)

    ├── App.tsx               # three-state rendering                             (§8, §9)

    ├── App.test.tsx          # component tests, mocked api module                   (§10)

    ├── main.tsx              # entry point

    └── index.css

11.2 Testing a React appπŸ”—

Reference material for the project — not lectured. Tests use Vitest (a Vite-native test runner) and Testing Library, which asserts on what the user sees, not on component internals. Two levels:

  • api.test.ts stubs the global fetch no server needed — and checks the client parses tasks, builds query strings, and throws ApiError with the right status on 404. (Same “fake the boundary” idea as the backend’s FakeClient.)

  • App.test.tsx mocks the whole ./api module and drives the real UI: it asserts the loading text appears, then the task; that an error shows a Retry that recovers; that the form calls createTask; that the checkbox calls updateTask(1, { done: true }).

it("shows a loading state, then the tasks", async () => {
  vi.mocked(api.listTasks).mockResolvedValue([task]);
  render(<App />);
  expect(screen.getByText(/loading tasks/i)).toBeInTheDocument();     // state 1
  expect(await screen.findByText(/read react notes/i)).toBeInTheDocument(); // state 3
});

Layer the vocabulary as the eval lecture did: type checking proves the code is consistent with the contract (structural, total, free); tests prove it behaves correctly (behavioral, sampled, costs a little). You want both — they fail on different bugs.

11.3 Build & runπŸ”—

# 1. Backend (add CORS from Appendix B first), in the FastAPI project:
uvicorn app.main:app --reload            # http://127.0.0.1:8000

# 2. Frontend:
git clone git@github.com:software-dev-genai/exercises.git
cd exercises/08-terptasks-web

npm install
npm run dev                              # http://localhost:5173

# The checks (what CI runs):
npm run typecheck                        # tsc --noEmit  β†’ the type gate
npm test                                 # vitest run    β†’ 8 passed
npm run build                            # typecheck + production bundle in dist/

Open http://localhost:5173, add a task, check it off, delete it — and watch the requests land in the uvicorn log. That is your full stack, running.

Practice ExercisesπŸ”—

BasicπŸ”—
  1. In TaskItem, add the due date next to the course, but only when task.due is not null. Explain why {task.due && <em>…</em>} is the idiomatic way and what would render if you wrote {task.due ? task.due : null} vs. {task.due}.

  2. The NewTaskForm submit button is disabled while submitting. Add the same guard so the form can’t be submitted with an empty title or empty course. Which piece of state drives the disable, and where does it live?

  3. Explain, in terms of the render loop, why tasks.push(x); setTasks(tasks) fails to update the screen but setTasks([...tasks, x]) works.

  4. Line up models.py and types.ts. For each of due, priority, and id, state the Python type, the TypeScript type, and why they differ (if they do).

  5. (Appendix B.) You load the page and see a permanent “Loading tasks…” with a CORS error in the console. Which file do you edit to fix it — a React file or a FastAPI file — and why can’t it be fixed in the browser?

IntermediateπŸ”—
  • 6. Add a filter UI: a dropdown of courses that calls GET /tasks?course=X. Put the selected course in state, and make the useEffect re-run when it changes (hint: the dependency array). What happens to the loading state on each change?

  • 7. Replace the fetch calls in api.ts with Axios (npm install axios). Delete the manual res.ok check and JSON.stringify; move the ApiError translation into an Axios error handler. Keep the public function signatures identical so no component changes. What did you gain and lose?

  • 8. Add runtime validation with Zod: define a TaskSchema, and in handle, parse the response with TaskSchema.array().parse(json). Now break the contract (have the server send titel) and observe the difference between the TypeScript cast (silent) and the Zod parse (throws). Which bug does each catch?

  • 9. Implement optimistic updates for toggleDone: flip the checkbox in state immediately, fire the PATCH, and roll back if it fails. What new state or error handling does this require, and what does the user experience gain?

  • 10. Write a Vitest test for NewTaskForm alone (not through App): render it with a mock onCreate, type a title and course, submit, and assert onCreate was called once with the right TaskCreate. Then assert the form clears only after the promise resolves.

AdvancedπŸ”—
  • 11. Kill the hand-mirrored types. TerpTasks serves an OpenAPI schema at /openapi.json. Use openapi-typescript to generate types.ts from it as part of npm run build. Now change a field in models.py and show that tsc noEmit fails on the frontend without anyone editing types.ts by hand. Discuss what this does to the “mirror stays honest” problem — and its new failure mode (schema drift between generation runs).

  • 12. Add authenticated requests. Suppose TerpTasks requires a Bearer token (RBAC lecture). Add an Axios interceptor (or a fetch wrapper) that attaches the token to every request and, on a 401, redirects to a login screen — without touching any of the four API functions’ call sites. Explain why the interceptor/wrapper is the right layer, referencing the backend’s dependency-injection pattern.

  • 13. Design the loading UX at scale. For a list of 500 tasks with per-row toggles, specify: how you avoid a full-list spinner on every single toggle (per-row pending state), how you prevent a slow toggle from clobbering a newer one (request ordering/cancellation), and how you’d measure whether the UI feels fast (which of the eval lecture’s latency percentiles matter to a user, and where you’d instrument the frontend to capture them).

SummaryπŸ”—

Key takeawaysπŸ”—
  1. The UI is a function of state. You declare what the screen looks like for a given state and change state via setters; React does the DOM. You never synchronize by hand — that kills the whole class of “screen disagrees with data” bugs.

  2. Data down, events up. Props flow from parent to child and are read-only; children request changes by calling callbacks the parent supplied. Split components into presentational (render props) and container (own state).

  3. useState for values, useEffect for the outside world. Never mutate state in place — produce a new array/object. Effects run after render; the dependency array decides when they re-run ([] = once on mount).

  4. Types mirror the backend contract. types.ts is a hand-copy of models.py; the tricky translations (datestring|null, enum→string-union, optional-vs-nullable) are where full-stack bugs concentrate. Types are erased at runtime — a cast is a promise, not a check.

  5. One typed client module. Centralize fetch/Axios in api.ts; handle the res.ok gotcha and the {"detail"} error shape once; carry the HTTP status in an ApiError. Read the base URL from VITE_API_URL, don’t hard-code it.

  6. Every network screen has three states loading, error, data (and empty is data). Render all of them or ship the “flash of undefined” bug.

  7. tsc noEmit is a CI gate that tests can’t replace. It checks every line against the contract structurally, for free; it caught a real bug in this very project that the passing tests missed. Keep strict: true on.

TerminologyπŸ”—

Term

Definition

Component

A function taking props and returning JSX (a UI description)

Props

Read-only input object passed parent → child

State

A component’s mutable memory, changed via a setter, driving re-renders

Hook

A use* function adding state/effects to a component

useState

Hook returning [value, setValue]

useEffect

Hook running side effects after render, keyed on a dependency array

Custom hook

A use* function composing other hooks to reuse stateful logic

Controlled component

A form input whose value is React state

JSX

HTML-like syntax compiling to function calls; {} embeds JS

Interface / type

A TypeScript description of an object’s shape (erased at runtime)

CORS

Server-granted permission for a cross-origin browser request

tsc noEmit

Type-check the whole project, emit nothing, nonzero exit on error

Common mistakesπŸ”—
  • Mutating state in place (arr.push) and wondering why the screen doesn’t update.

  • Rendering only the happy path — no loading, no error, no empty — and shipping a blank/crashing screen.

  • Forgetting fetch resolves on 404/500; treating an error body as data because you skipped res.ok.

  • Typing due as Date when JSON delivers a string, or forgetting a field can be null.

  • Believing a TypeScript cast validates the response at runtime (it doesn’t; that’s Zod’s job).

  • Missing or index-based key on a mapped list, causing wrong-row updates.

  • Trying to fix a CORS error in React instead of on the server.

  • Turning off strict to silence null errors — deferring the crash to production.

  • Treating tsc as optional because “the tests pass” — they check different things.

ConnectionsπŸ”—
  • Backward: the FastAPI lecture built the routes and Pydantic models this frontend mirrors; the REST lecture’s verbs/status codes are exactly what api.ts sends and handle interprets; layered architecture reappears as components-vs-hooks; the testing lecture’s fakes and CI gate return as stubbed fetch and tsc noEmit; the eval lecture’s “gate on every change” is the same discipline, and its latency percentiles reappear in exercise 13.

  • Forward: state management beyond one hook (Context, then libraries like Redux/Zustand) when state must be shared across distant components; data-fetching libraries (TanStack Query) that turn §8’s three states + caching into one line; server-side rendering (Next.js); and generating types.ts from OpenAPI (exercise 11) so the mirror can never drift — the industrial answer to the theme of this lecture.

Appendix A. Extracting a Custom Hook: useTasksπŸ”—

Not covered in the 75-minute lecture. You will want it for the project, and the shipped code in §11 uses it, so it is written up here.

The TaskListApp of §9 works, but it is getting busy: it needs the task array, a loading flag, an error, and four actions. Dumping all of that into the component mixes “how do I talk to the server” with “how do I draw the screen.” The fix is a custom hook.

A custom hook is a function whose name starts with use and that calls other hooks. It lets you extract stateful logic into a reusable, testable unit. It is not a component — it returns data and functions, not JSX.

Plain English. A custom hook is “a component’s brain without its face.” useTasks() owns all TerpTasks server state; components just consume it.

Intuition — this is the layered architecture, again. The backend separates routes (HTTP) from storage (data). The frontend separates components (rendering) from hooks (state + network). Same principle — isolate the cross-cutting concern in one layer one lecture later, on the other side of the wire.

Here is the whole hook, and it ties together every concept in the lecture:

export function useTasks(): UseTasks {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const reload = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      setTasks(await listTasks());          // Β§7 typed client
    } catch (err) {
      setError(messageFor(err));            // Β§8 error state
    } finally {
      setLoading(false);                    // runs on success AND failure
    }
  }, []);

  useEffect(() => { void reload(); }, [reload]);   // Β§5 load on mount

  const addTask = useCallback(async (data: TaskCreate) => {
    const created = await createTask(data);
    setTasks((prev) => [...prev, created]);        // Β§4 new array, no mutation
  }, []);

  const toggleDone = useCallback(async (task: Task) => {
    const updated = await updateTask(task.id, { done: !task.done });
    setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
  }, []);

  const removeTask = useCallback(async (id: number) => {
    await deleteTask(id);
    setTasks((prev) => prev.filter((t) => t.id !== id));
  }, []);

  return { tasks, loading, error, reload, addTask, toggleDone, removeTask };
}

useCallback memoizes each function so its identity is stable across renders — which is what lets useEffect([reload]) run exactly once instead of looping. (In §9 the same effect used [] because loadTasks was redefined on every render and therefore unusable as a dependency; useCallback is what makes [reload] safe.)

With the hook in place, App shrinks to its actual job — drawing:

const { tasks, loading, error, reload, addTask, toggleDone } = useTasks();

Appendix B. CORS: The Full-Stack Gotcha You Will HitπŸ”—

Not covered in the 75-minute lecture, but you will hit this the first time you run the frontend against the backend — so read it before lab.

You start the backend on :8000, the frontend dev server on :5173, click “load,” and get nothing an empty list and a scary red console message about “CORS policy.” This trips up everyone the first time, so let’s name it.

The Same-Origin Policy is a browser security rule: JavaScript from origin A (scheme + host + port) may not read responses from origin B unless B explicitly allows it via CORS (Cross-Origin Resource Sharing) headers.

http://localhost:5173 and http://127.0.0.1:8000 are different origins (different ports). So the browser blocks your frontend from reading the API’s response — by default, to protect users. (Your curl tests worked because curl isn’t a browser and doesn’t enforce this.)

The fix is on the server it must opt in. Add to TerpTasks’ app/main.py:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],  # the dev frontend's origin
    allow_methods=["*"],                       # GET, POST, PATCH, DELETE
    allow_headers=["*"],
)

Common misconception: “CORS is a frontend bug I can fix in React.” You cannot fix it from the browser — that’s the entire point of the policy. The server that owns the data decides who may read it. CORS errors are always resolved server-side (or with a dev proxy). Recognizing this on sight saves an hour of editing the wrong file.