8 React/TypeScript Frontend & Full-Stack Integration
The companion project for this lecture is 08-terptasks-web in the
course exercises repository —
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:
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. 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. 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. 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. Diagnose the full-stack seams —
thread VITE_API_URL through config, and trace one button click from the browser to the database and back. 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 —
This lecture builds that thing: the frontend —
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 —
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 —
1.2 Why TypeScript?
Your backend already validates data —
TypeScript moves that error to compile time. You write down the
shape of a Task once, as an interface, and every misuse
—
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 —
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 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 --versionIf 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 installThe 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 devYou 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 —
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 —
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 —
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 —
The smallest possible component. Before the real thing, here is a
component with no props at all —
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 —
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 —
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 —
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 —
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 explicitlyAside: 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 —
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 —
Intuition —
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-rendersReact 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 —
<input value={title} onChange={(e) => setTitle(e.target.value)} />Every keystroke fires onChange → setTitle → 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 —
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 —
Intuition. Rendering must be pure (no network calls, no
timers —
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 — |
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 —
useEffect(() => {
void loadTasks(); // `void` says "I intentionally don't await this Promise"
}, []); // empty deps β runs once, after the first renderWe’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 —
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:
date → string | 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 —
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 —
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 —
# 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 —
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 —
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 automaticallyfetch | Axios | |
Install | Built in | npm install axios |
JSON | Manual JSON.stringify / res.json() | Automatic both ways |
Errors on 404/500 | Resolves normally (ok === false) — | 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 —
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 —
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
—
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
—
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 —
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 —
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 —
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 —
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 —
// 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 testThis is not hypothetical —
Common misconception: “strict mode is pedantic; I’ll turn it
off.” strict: true (in tsconfig.json) is what turns on
null checking —
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 —
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 —
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 —
Practice Exercises
Basic
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}.
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?
Explain, in terms of the render loop, why tasks.push(x); setTasks(tasks) fails to update the screen but setTasks([...tasks, x]) works.
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).
(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
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. 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).
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). Types mirror the backend contract. types.ts is a hand-copy of models.py; the tricky translations (date→string|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. 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.
Every network screen has three states —
loading, error, data (and empty is data). Render all of them or ship the “flash of undefined” bug. 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 —
Plain English. A custom hook is “a component’s brain without its face.” useTasks() owns all TerpTasks server state; components just consume it.
Intuition —
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 —
With the hook in place, App shrinks to its actual job —
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 —
You start the backend on :8000, the frontend dev server on
:5173, click “load,” and get nothing —
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 —
The fix is on the server —
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 —