Building AI Features in React

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.

Request 2 owns the UI; request 1 is still talkingidleloading, id 2streaming,id 2done, id 2nullchunk fromid 1: ignoredcancel fromid 1: ignorederror and cancelled branch off loading and streaming, keeping the partial text.
Tag every event with its request id and the stale-response race becomes one line in the reducer.

The six states

StateWhat the shopper seesData it carries
idleNothing yet, or a Start buttonnothing
loadingA skeleton; no text has arrivedrequest id
streamingText growing, a caret, a Stop buttonrequest id, text so far
doneThe full summary, Regenerate and feedback controlsrequest id, final text
errorA message, partial text if any, maybe Try againrequest id, message, retryable, partial text
cancelledThe 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:

  • start from any state goes to loading, with a new id.
  • chunk from loading goes to streaming; from streaming it appends text.
  • succeed from loading or streaming goes to done.
  • fail from loading or streaming goes to error, keeping any partial text.
  • cancel from 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

TypeScript
// src/state/requestMachine.tsexport type RequestState<T> =  | { status: "idle" }  | { status: "loading"; id: number }  | { status: "streaming"; id: number; text: string }  | { status: "done"; id: number; data: T }  | { status: "error"; id: number; message: string; retryable: boolean; partial: string }  | { status: "cancelled"; id: number; partial: string };export type RequestEvent<T> =  | { type: "start"; id: number }  | { type: "chunk"; id: number; text: string }  | { type: "succeed"; id: number; data: T }  | { type: "fail"; id: number; message: string; retryable: boolean }  | { type: "cancel"; id: number };export function requestReducer<T>(state: RequestState<T>, event: RequestEvent<T>): RequestState<T> {  if (event.type === "start") return { status: "loading", id: event.id };  if (state.status !== "loading" && state.status !== "streaming") return state; // finished: ignore  if (event.id !== state.id) return state; // an event from a stale request: ignore  const partial = state.status === "streaming" ? state.text : "";  switch (event.type) {    case "chunk":      return { status: "streaming", id: state.id, text: partial + event.text };    case "succeed":      return { status: "done", id: state.id, data: event.data };    case "fail":      return { status: "error", id: state.id, message: event.message, retryable: event.retryable, partial };    case "cancel":      return { status: "cancelled", id: state.id, partial };  }}export function visibleText(state: RequestState<string>): string {  switch (state.status) {    case "streaming": return state.text;    case "done": return state.data;    case "error":    case "cancelled": return state.partial;    default: return "";  }}

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

TypeScript
// src/hooks/useStreamingSummary.tsimport { useCallback, useEffect, useReducer, useRef } from "react";import { ApiError, streamText } from "../api/client";import { requestReducer, type RequestState } from "../state/requestMachine";export function useStreamingSummary(productId: string) {  const [state, dispatch] = useReducer(requestReducer<string>, { status: "idle" } as RequestState<string>);  const controller = useRef<AbortController | null>(null);  const nextId = useRef(0);  const start = useCallback(() => {    controller.current?.abort();    const id = ++nextId.current;    const ctrl = new AbortController();    controller.current = ctrl;    dispatch({ type: "start", id });    let text = "";    streamText("/api/summary", { productId }, (chunk) => {      text += chunk;      dispatch({ type: "chunk", id, text: chunk });    }, ctrl.signal)      .then(() => dispatch({ type: "succeed", id, data: text }))      .catch((err: unknown) => {        if (ctrl.signal.aborted) return dispatch({ type: "cancel", id });        const retryable = err instanceof ApiError ? err.retryable : true;        dispatch({ type: "fail", id, message: err instanceof Error ? err.message : "Failed", retryable });      });  }, [productId]);  const stop = useCallback(() => controller.current?.abort(), []);  useEffect(() => {    start();    return () => controller.current?.abort();  }, [start]);  return { state, start, stop };}

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:

TypeScript
// src/state/requestMachine.test.tsimport { expect, test } from "vitest";import { requestReducer, type RequestState } from "./requestMachine";test("ignores chunks from a stale request", () => {  let s: RequestState<string> = { status: "idle" };  s = requestReducer(s, { type: "start", id: 1 });  s = requestReducer(s, { type: "start", id: 2 });              // shopper switched product  s = requestReducer(s, { type: "chunk", id: 1, text: "old" });  // late chunk from request 1  expect(s).toEqual({ status: "loading", id: 2 });});test("keeps partial text when cancelled", () => {  let s: RequestState<string> = requestReducer({ status: "idle" }, { type: "start", id: 1 });  s = requestReducer(s, { type: "chunk", id: 1, text: "Most buyers" });  s = requestReducer(s, { type: "cancel", id: 1 });  expect(s).toEqual({ status: "cancelled", id: 1, partial: "Most buyers" });});

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?