Course Content
Building AI Features in React
5 sections · 21 lessons
Request State as a State Machine
The first summary hook has two pieces of state: text and status. Together they allow combinations that should never exist. Status "error" with the text of the previous summary still showing. Status "idle" with text. Status "cancelled" while a new summary is streaming, which is the race the ownership checks prevent, as long as nobody forgets one. Two variables with 5 statuses and "text or no text" give 10 combinations, and only 6 of them make sense.
A bug report from testing showed one of the others: after a failure, the shopper pressed "Try again", and for one frame the old, half-finished summary appeared above the error message. Nothing crashed. The UI simply rendered a state that should not exist. This lesson replaces the loose variables with a state machine: a fixed list of states, and a single function that decides how each event changes the state.
The six states
| State | What the shopper sees | Data it carries |
|---|---|---|
| idle | Nothing yet, or a Start button | nothing |
| loading | A skeleton; no text has arrived | request id |
| streaming | Text growing, a caret, a Stop button | request id, text so far |
| done | The full summary, Regenerate and feedback controls | request id, final text |
| error | A message, partial text if any, maybe Try again | request id, message, retryable, partial text |
| cancelled | The partial text and "Stopped" | request id, partial text |
The difference between loading and streaming matters. Loading is the 0.8 seconds before the first word, and it shows a skeleton. Streaming shows real text. In the first hook, both were "streaming", so the skeleton could never be shown.
Events and transitions
The hook no longer sets state directly. It sends events, and the reducer decides the result:
startfrom any state goes to loading, with a new id.chunkfrom loading goes to streaming; from streaming it appends text.succeedfrom loading or streaming goes to done.failfrom loading or streaming goes to error, keeping any partial text.cancelfrom loading or streaming goes to cancelled, keeping any partial text.- Any event with an old request id is ignored. Any event not listed above is ignored.
The last line is where the stale-request bug disappears. It is written once, in the reducer, instead of in every callback.
The reducer
1// src/state/requestMachine.ts2export type RequestState<T> =3 | { status: "idle" }4 | { status: "loading"; id: number }5 | { status: "streaming"; id: number; text: string }6 | { status: "done"; id: number; data: T }7 | { status: "error"; id: number; message: string; retryable: boolean; partial: string }8 | { status: "cancelled"; id: number; partial: string };910export type RequestEvent<T> =11 | { type: "start"; id: number }12 | { type: "chunk"; id: number; text: string }13 | { type: "succeed"; id: number; data: T }14 | { type: "fail"; id: number; message: string; retryable: boolean }15 | { type: "cancel"; id: number };1617export function requestReducer<T>(state: RequestState<T>, event: RequestEvent<T>): RequestState<T> {18 if (event.type === "start") return { status: "loading", id: event.id };19 if (state.status !== "loading" && state.status !== "streaming") return state; // finished: ignore20 if (event.id !== state.id) return state; // an event from a stale request: ignore2122 const partial = state.status === "streaming" ? state.text : "";23 switch (event.type) {24 case "chunk":25 return { status: "streaming", id: state.id, text: partial + event.text };26 case "succeed":27 return { status: "done", id: state.id, data: event.data };28 case "fail":29 return { status: "error", id: state.id, message: event.message, retryable: event.retryable, partial };30 case "cancel":31 return { status: "cancelled", id: state.id, partial };32 }33}3435export function visibleText(state: RequestState<string>): string {36 switch (state.status) {37 case "streaming": return state.text;38 case "done": return state.data;39 case "error":40 case "cancelled": return state.partial;41 default: return "";42 }43}Each state is one branch of a discriminated union: the status field tells TypeScript which other fields exist. A done state has data and cannot have partial; an idle state has no id at all. The "error with old text" combination from the bug report cannot be written down, so it cannot be rendered.
The reducer is a plain function with no React, no fetch and no timers, so its rules are easy to read and easy to test. The switch covers all event types after start, and TypeScript checks that every path returns a state.
The summary hook, rebuilt
1// src/hooks/useStreamingSummary.ts2import { useCallback, useEffect, useReducer, useRef } from "react";3import { ApiError, streamText } from "../api/client";4import { requestReducer, type RequestState } from "../state/requestMachine";56export function useStreamingSummary(productId: string) {7 const [state, dispatch] = useReducer(requestReducer<string>, { status: "idle" } as RequestState<string>);8 const controller = useRef<AbortController | null>(null);9 const nextId = useRef(0);1011 const start = useCallback(() => {12 controller.current?.abort();13 const id = ++nextId.current;14 const ctrl = new AbortController();15 controller.current = ctrl;16 dispatch({ type: "start", id });1718 let text = "";19 streamText("/api/summary", { productId }, (chunk) => {20 text += chunk;21 dispatch({ type: "chunk", id, text: chunk });22 }, ctrl.signal)23 .then(() => dispatch({ type: "succeed", id, data: text }))24 .catch((err: unknown) => {25 if (ctrl.signal.aborted) return dispatch({ type: "cancel", id });26 const retryable = err instanceof ApiError ? err.retryable : true;27 dispatch({ type: "fail", id, message: err instanceof Error ? err.message : "Failed", retryable });28 });29 }, [productId]);3031 const stop = useCallback(() => controller.current?.abort(), []);3233 useEffect(() => {34 start();35 return () => controller.current?.abort();36 }, [start]);3738 return { state, start, stop };39}The callbacks no longer check anything. They dispatch events tagged with their request's id, and the reducer ignores the ones that are stale. requestReducer<string> is an instantiation expression, which fixes the reducer's type parameter so that done carries the final summary text. BuyerSummary now switches on state.status: a skeleton for loading, visibleText(state) plus a caret and Stop for streaming, and so on. Every branch receives exactly the fields it needs.
Testing the rules without a network
Because the reducer is a pure function, its rules can be tested in a few lines with Vitest or Jest:
1// src/state/requestMachine.test.ts2import { expect, test } from "vitest";3import { requestReducer, type RequestState } from "./requestMachine";45test("ignores chunks from a stale request", () => {6 let s: RequestState<string> = { status: "idle" };7 s = requestReducer(s, { type: "start", id: 1 });8 s = requestReducer(s, { type: "start", id: 2 }); // shopper switched product9 s = requestReducer(s, { type: "chunk", id: 1, text: "old" }); // late chunk from request 110 expect(s).toEqual({ status: "loading", id: 2 });11});1213test("keeps partial text when cancelled", () => {14 let s: RequestState<string> = requestReducer({ status: "idle" }, { type: "start", id: 1 });15 s = requestReducer(s, { type: "chunk", id: 1, text: "Most buyers" });16 s = requestReducer(s, { type: "cancel", id: 1 });17 expect(s).toEqual({ status: "cancelled", id: 1, partial: "Most buyers" });18});These tests run in milliseconds and cover exactly the races that are hardest to reproduce by hand. Section 5 adds tests that drive the whole hook with a fake stream.
Check your understanding
0 of 3 answered
1.Why does ShopLens separate "loading" from "streaming"?
2.Request 1 is aborted and request 2 starts. Request 1's cancel event arrives after that. What does the reducer do?
3.Which piece of UI state is better kept as a simple map instead of the request state machine?