Course Content
Building AI Features in React
5 sections · 21 lessons
Feature 2: A Streaming Summary With a Stop Button
The summary is the feature shoppers notice. It is also where streaming, limits and cancellation all meet. A summary that appears word by word in under a second feels fast, but only if every link in the chain streams. A Stop button feels responsive, but it only saves money if the abort travels all the way to the provider.
This lesson builds the summary route and the React side, and then follows a Stop press through every layer to show where it could silently fail.
The route, part 1: preconditions and cancellation
1// server/routes/summary.ts (part 1)2import type { Request, Response } from "express";3import { SummaryRequest } from "../../shared/schemas";4import { getProduct, getTopReviews } from "../data";5import { llm } from "../llm";6import { SUMMARY_SYSTEM, summaryUserPrompt } from "../prompts";78const MAX_WORDS = 80;9const SENTINEL = "NOT_ENOUGH_INFO";10const wordCount = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;1112export async function summaryRoute(req: Request, res: Response): Promise<void> {13 const input = SummaryRequest.safeParse(req.body);14 if (!input.success) { res.status(400).json({ error: "Invalid request" }); return; }15 const { productId } = input.data;16 const [product, reviews] = await Promise.all([getProduct(productId), getTopReviews(productId, 40)]);17 if (!product) { res.status(404).json({ error: "Unknown product" }); return; }18 if (reviews.length < 5) { res.status(422).json({ error: "not_enough_reviews" }); return; }1920 const upstream = new AbortController();21 res.on("close", () => upstream.abort()); // Stop, navigation or a closed tab2223 try {24 const outcome = await pipeSummary(res, llm.stream({25 tier: "main", system: SUMMARY_SYSTEM, user: summaryUserPrompt(product.name, reviews),26 maxTokens: 300, signal: upstream.signal,27 }));28 if (outcome === "sentinel") { res.status(422).json({ error: "not_enough_reviews" }); return; }29 res.end();30 } catch (err) {31 if (upstream.signal.aborted) return; // the shopper left; there is no one to answer32 if (!res.headersSent) res.status(502).json({ error: "Summary unavailable" });33 else res.destroy(); // mid-stream failure: cut the connection so the client sees it34 } finally {35 upstream.abort(); // if generation is still running for any reason, stop it now36 }37}The cheap checks come first: a valid body, a known product, at least 5 reviews. None of them costs a model call. Then the route creates its own AbortController for the model call and connects it to the response's close event. When the browser disconnects, for whatever reason, the model call is aborted. The finally block aborts again after every path; aborting a finished call does nothing, and it guarantees no path leaves generation running.
The route, part 2: holding, limiting and writing
1// server/routes/summary.ts (part 2)2function begin(res: Response, text: string) {3 res.setHeader("Content-Type", "text/plain; charset=utf-8");4 res.setHeader("Cache-Control", "no-store");5 res.write(text); // the first write sends the headers6}78async function pipeSummary(9 res: Response, chunks: AsyncIterable<string>,10): Promise<"sent" | "sentinel" | "cut"> {11 let text = "";12 for await (const chunk of chunks) {13 text += chunk;14 if (!res.headersSent) {15 if (SENTINEL.startsWith(text.trim())) continue; // could still be the sentinel: hold16 begin(res, text);17 } else if (wordCount(text) > MAX_WORDS) {18 res.write(" …");19 return "cut"; // leaving the loop ends the model stream20 } else {21 res.write(chunk);22 }23 }24 if (text.trim() === SENTINEL) return "sentinel";25 if (!res.headersSent) begin(res, text); // a very short reply that never left the hold26 return "sent";27}This function does three things in one pass. First, it holds the start of the stream while the text so far could still become NOT_ENOUGH_INFO. As soon as the text stops matching, which is usually after the first token, everything held is written at once. The cost is a delay of one or two tokens, about 30 ms, and the benefit is that the sentinel never reaches the shopper and can still become a proper 422 response, because no headers have been sent. Second, it limits: once the text passes 80 words, it writes an ellipsis and returns. Returning from inside for await closes the model stream, and the finally block in part 1 aborts it too. Third, it writes each new chunk as soon as it arrives. Word counting runs on the whole text, not on each chunk, because a chunk can end halfway through a word.
The hook
1// src/hooks/useStreamingSummary.ts (first version)2import { useCallback, useEffect, useRef, useState } from "react";3import { streamText } from "../api/client";45type Status = "idle" | "streaming" | "done" | "error" | "cancelled";67export function useStreamingSummary(productId: string) {8 const [text, setText] = useState("");9 const [status, setStatus] = useState<Status>("idle");10 const active = useRef<AbortController | null>(null);1112 const start = useCallback(() => {13 active.current?.abort(); // one summary at a time14 const controller = new AbortController();15 active.current = controller;16 setText("");17 setStatus("streaming");1819 streamText("/api/summary", { productId }, (chunk) => {20 if (active.current === controller) setText((t) => t + chunk);21 }, controller.signal)22 .then(() => { if (active.current === controller) setStatus("done"); })23 .catch(() => {24 if (active.current !== controller) return; // a newer request owns the UI25 setStatus(controller.signal.aborted ? "cancelled" : "error");26 });27 }, [productId]);2829 const stop = useCallback(() => active.current?.abort(), []);3031 useEffect(() => {32 start();33 return () => active.current?.abort();34 }, [start]);3536 return { text, status, start, stop };37}The ref active holds the controller of the request that currently owns the UI. Every callback checks active.current === controller before touching state. Without that check, here is the bug: the shopper switches product, start aborts the old request and begins a new one, and a moment later the old request's catch runs and sets the status to "cancelled" while the new summary is streaming. In development, React 18's Strict Mode runs the effect twice, so this guard also handles the first, immediately aborted request. Thanks to the server's close handler, that doubled request costs almost nothing.
The component
1// src/components/BuyerSummary.tsx (first version)2import { useId } from "react";3import { useStreamingSummary } from "../hooks/useStreamingSummary";45export function BuyerSummary({ productId }: { productId: string }) {6 const { text, status, start, stop } = useStreamingSummary(productId);7 const titleId = useId();89 return (10 <section className="buyer-summary" aria-labelledby={titleId}>11 <div className="buyer-summary__head">12 <h2 id={titleId}>What buyers say</h2>13 {status === "streaming" && <button type="button" onClick={stop}>Stop</button>}14 </div>15 <p className="buyer-summary__text" aria-busy={status === "streaming"}>16 {text}17 {status === "streaming" && <span className="caret" aria-hidden="true" />}18 </p>19 {status === "cancelled" && (20 <p className="muted">Stopped. <button type="button" onClick={start}>Start again</button></p>21 )}22 {status === "error" && (23 <p className="muted">Summary unavailable. <button type="button" onClick={start}>Try again</button></p>24 )}25 </section>26 );27}The text is rendered as a normal React child, so React escapes it; nothing the model writes can become HTML. There is no aria-live on the streaming paragraph on purpose: a live region would make a screen reader announce every chunk, dozens of times per summary. Section 4 adds a single announcement when the summary is complete.
Following one Stop press
- Button —
stop()callsabort()on the active controller. - Browser —
reader.read()rejects, the fetch is cancelled and the TCP connection to the BFF closes. - Hook — the
catchseessignal.abortedand sets the status to "cancelled"; the partial text stays on screen. - BFF — Express emits
closeon the response, and the route abortsupstream. - SDK — the aborted signal closes the connection to the provider, which stops generating.
Test the last two steps, not just the first three. It is easy to build a Stop button that updates the UI while the server keeps generating to the end, still paying for every token. Add a log line in the close handler and one when the model stream ends, press Stop, and check the timestamps are milliseconds apart.
Check your understanding
0 of 3 answered
1.Why does the server hold the first few characters of the stream before writing anything?
2.The Stop button updates the UI, but provider usage shows every summary running to its full length. What is the most likely gap?
3.A shopper switches from product A to product B while A's summary is streaming. Without the active.current === controller checks, what can go wrong?