Course Content
Building AI Features in React
5 sections · 21 lessons
Edit, Regenerate and Feedback Controls
The first Regenerate button on the ShopLens summary cleared the text and streamed a new summary. In practice, the new summary said almost the same thing 70 percent of the time, because the reviews and the prompt had not changed. Some shoppers pressed it three or four times, hoping for something different, which cost money and showed them nothing new. And because the old text disappeared at once, they could not compare the two versions anyway.
Controls around AI output are easy to add and hard to make useful. This lesson looks at the three that matter most, regenerate, edit and feedback, and asks for each one: what problem does it solve for this user, and what does it cost?
What Regenerate is for
For a shopper, a new summary of the same reviews is rarely more useful than the first. Regenerate earns its place in one situation: the first answer was bad, for example cut short, confusing or clearly wrong. So ShopLens treats it as a recovery action, not an exploration tool:
- It appears only when a summary is done, as a small "Not right? Write it again" link, not as a prominent button.
- It skips the cache, since the cached summary is the one the shopper did not like.
- It is limited to two per product per session, on the server as well as in the UI.
- While the new summary loads, the old one stays on screen, dimmed, until the first word of the new one arrives.
In an internal tool, the answer can be different. A support agent drafting a reply may want three versions to choose from. There, regenerating is exploration and worth making prominent. Decide based on whether a different answer has real value to this user.
Holding the old summary until new text arrives
Only the hook knows when the new stream produces its first chunk, so the hook decides how long the old summary stays. Here is the complete hook from section 3 with Regenerate added:
1// src/hooks/useStreamingSummary.ts (with Regenerate)2import { useCallback, useEffect, useReducer, useRef, useState } 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 [held, setHeld] = useState<string | null>(null); // the old summary, kept during a regenerate9 const controller = useRef<AbortController | null>(null);10 const nextId = useRef(0);11 const lastDone = useRef<string | null>(null);12 useEffect(() => { if (state.status === "done") lastDone.current = state.data; }, [state]);1314 const start = useCallback((options: { fresh?: boolean } = {}) => {15 controller.current?.abort();16 const id = ++nextId.current;17 const ctrl = new AbortController();18 controller.current = ctrl;19 const keep = options.fresh ? lastDone.current : null;20 setHeld(keep);21 dispatch({ type: "start", id });2223 let text = "";24 streamText("/api/summary", { productId, fresh: options.fresh === true }, (chunk) => {25 text += chunk;26 dispatch({ type: "chunk", id, text: chunk });27 }, ctrl.signal)28 .then(() => dispatch({ type: "succeed", id, data: text }))29 .catch((err: unknown) => {30 if (keep !== null && text === "") return dispatch({ type: "succeed", id, data: keep }); // restore31 if (ctrl.signal.aborted) return dispatch({ type: "cancel", id });32 const retryable = err instanceof ApiError ? err.retryable : true;33 dispatch({ type: "fail", id, message: err instanceof Error ? err.message : "Failed", retryable });34 });35 }, [productId]);3637 const stop = useCallback(() => controller.current?.abort(), []);38 useEffect(() => {39 start();40 return () => controller.current?.abort();41 }, [start]);4243 const previous = state.status === "loading" ? held : null;44 return { state, start, stop, previous };45}Three things changed. First, start takes an options object, and fresh travels in the request body, where SummaryRequest already accepts it and the route skips the cache. start() with no argument, as the effect and "Try again" call it, works as before.
Second, the hook remembers the last finished summary: an effect copies state.data into the lastDone ref whenever the state is done. It is a ref so that start can read it without depending on state; otherwise start would change with every chunk, and the effect that calls it would restart the stream. A regenerate copies that text into held, and the hook returns it as previous only while the state is loading. The first chunk moves the reducer to streaming, so previous becomes null in the same render that shows the new text: never an empty box, and never both texts.
Third, the line marked restore. If a regenerate fails or is stopped before any text arrives, the hook dispatches succeed with the old summary, so the shopper is back where they started rather than facing an error about a summary they already had. That covers a network error, a 429 from the server's limit, and Stop pressed during the wait. After the first chunk, the new request owns the box, and a failure keeps its partial text as before. If the shopper switches product mid-regenerate, the restore carries a stale id and the reducer ignores it.
In BuyerSummary, from the first lesson of this section, the body checks previous before the skeleton:
1// src/components/BuyerSummary.tsx (the changed lines)2const { state, start, stop, previous } = useStreamingSummary(productId);34<div className="buyer-summary__body">5 {previous !== null ? (6 <p className="buyer-summary__replacing">{previous}</p>7 ) : state.status === "loading" ? (8 <SummarySkeleton lines={4} />9 ) : (10 <p>{text}{state.status === "streaming" && <span className="caret" aria-hidden="true" />}</p>11 )}12</div>The buyer-summary__replacing class sets opacity: 0.5 with a short transition, so the old text reads as "being replaced" without disappearing. The section's aria-busy already tells assistive technology the content is changing, and the live region still announces once, when the new summary is done.
The link itself enforces the limit in the UI:
1// src/components/SummaryFooter.tsx (the "done" branch renders DoneControls)2import { FeedbackBar } from "./FeedbackBar";34const MAX_REGENERATIONS = 2; // the summary route enforces the same limit5const regenerations = new Map<string, number>(); // product id -> count, for this page session67type DoneProps = { productId: string; text: string; onRetry: (options?: { fresh?: boolean }) => void };89function DoneControls({ productId, text, onRetry }: DoneProps) {10 const used = regenerations.get(productId) ?? 0;11 const regenerate = () => {12 regenerations.set(productId, used + 1);13 onRetry({ fresh: true });14 };15 return (16 <div className="summary-footer">17 <FeedbackBar productId={productId} text={text} />18 {used < MAX_REGENERATIONS && (19 <button type="button" className="link" onClick={regenerate}>Not right? Write it again</button>20 )}21 </div>22 );23}The count lives in a module-level map, outside React, because DoneControls unmounts during every regenerate (the footer renders nothing while loading), and component state would reset each time. The map lasts as long as the page. A reload resets it, which is fine: the server's limit is the real one, and the UI count only hides the link so shoppers rarely meet the 429.
Edit the input, not the output
A shopper should not edit the store's summary, but they can reasonably change what they ask for. In the compare feature, a row of aspect chips lets the shopper choose up to five aspects, such as Battery, Comfort, Noise cancelling and Call quality, or type their own of up to 30 characters. The request's aspects field, which the Zod schema already allows, carries the choice to the server, and the prompt uses exactly those aspects in that order.
Two design decisions keep this cheap. First, toggling a chip does not start a request. The shopper chooses, then presses "Update comparison", so five quick toggles are one call, not five. Second, custom aspects are limited to 30 characters by the schema, which keeps them from becoming a free-text prompt that someone could use to steer the model elsewhere. Section 5 covers why that matters.
Editing the output does make sense for other users. A merchant might edit the summary for their own product page before publishing it. If you build that, store the edited text alongside the original: the difference between them is one of the most valuable quality signals you can collect, because it shows exactly what an expert changed.
Feedback that someone can act on
A thumbs-down with nothing else tells you almost nothing. Free-text feedback boxes are rarely filled in, typically by 1 to 2 percent of users, and are hard to aggregate. ShopLens asks one step further with a small set of reasons:
1// src/components/FeedbackBar.tsx2import { useState } from "react";3import { postJson } from "../api/client";4import { FeedbackReply, type FeedbackReason } from "../../shared/schemas";56const REASONS: Record<FeedbackReason, string> = {7 wrong_facts: "Wrong facts",8 missing: "Missed something important",9 vague: "Too vague",10 offensive: "Offensive",11};1213export function FeedbackBar({ productId, text }: { productId: string; text: string }) {14 const [step, setStep] = useState<"ask" | "why" | "thanks">("ask");1516 const submit = (vote: "up" | "down", reason?: FeedbackReason) => {17 setStep("thanks");18 postJson("/api/feedback", { feature: "summary", productId, text, vote, reason }, FeedbackReply)19 .catch(() => { /* feedback is best effort; never bother the shopper */ });20 };2122 if (step === "thanks") return <p className="muted">Thanks for the feedback.</p>;23 if (step === "why") {24 return (25 <div role="group" aria-label="What was wrong?">26 {(Object.keys(REASONS) as FeedbackReason[]).map((r) => (27 <button key={r} type="button" onClick={() => submit("down", r)}>{REASONS[r]}</button>28 ))}29 <button type="button" className="link" onClick={() => submit("down")}>Skip</button>30 </div>31 );32 }33 return (34 <div role="group" aria-label="Was this summary helpful?">35 <button type="button" onClick={() => submit("up")} aria-label="Helpful">Yes</button>36 <button type="button" onClick={() => setStep("why")} aria-label="Not helpful">No</button>37 </div>38 );39}The bar moves through three steps: ask, why and thanks. It thanks the shopper before the request finishes and ignores any failure, because someone who took the trouble to give feedback should never see an error for it.
The feedback schema and route
The bar depends on two pieces: a schema shared by both sides, and a route.
1// shared/schemas.ts (part 3)2export const FeedbackReason = z.enum(["wrong_facts", "missing", "vague", "offensive"]);3export type FeedbackReason = z.infer<typeof FeedbackReason>;45export const FeedbackRequest = z6 .object({7 feature: z.enum(["summary", "compare"]),8 productId: z.string().min(1),9 text: z.string().min(1).max(2000), // the exact output the shopper judged10 vote: z.enum(["up", "down"]),11 reason: FeedbackReason.optional(),12 })13 .refine((f) => f.vote === "down" || f.reason === undefined, {14 message: "Only a down vote can have a reason",15 path: ["reason"],16 });17export type FeedbackRequest = z.infer<typeof FeedbackRequest>;1819export const FeedbackReply = z.object({ ok: z.literal(true) });20export type FeedbackReply = z.infer<typeof FeedbackReply>;FeedbackReason does two jobs. The server checks the reason against it, and FeedbackBar uses its type in Record<FeedbackReason, string>, so a new reason is a type error until the bar gives it a label. The 2,000-character text limit is generous on purpose: an 80-word summary is about 600 characters in English, but Hindi or Tamil runs longer, and 2,000 characters still fit well inside the 10 KB body limit. The refine rejects a reason on an up vote, which the bar never sends, so such a request came from something else. FeedbackReply exists because postJson requires a schema for every reply, even { ok: true }.
The text is sent with the feedback so the server knows exactly which output the shopper saw, even though every summary is different. The route then adds what the browser cannot know, or should not be trusted to say:
1// server/routes/feedback.ts2import type { Request, Response } from "express";3import { FeedbackRequest } from "../../shared/schemas";4import { queueForReview, saveFeedback } from "../data";5import { COMPARE_PROMPT_VERSION, SUMMARY_PROMPT_VERSION } from "../prompts";67const PROMPT_VERSION = { summary: SUMMARY_PROMPT_VERSION, compare: COMPARE_PROMPT_VERSION } as const;89export async function feedbackRoute(req: Request, res: Response): Promise<void> {10 const input = FeedbackRequest.safeParse(req.body);11 if (!input.success) { res.status(400).json({ error: "Invalid feedback" }); return; }1213 // Add what the browser cannot know, or should not be trusted to say.14 const record = {15 ...input.data,16 promptVersion: PROMPT_VERSION[input.data.feature],17 receivedAt: new Date().toISOString(),18 };19 await saveFeedback(record);20 if (record.reason === "wrong_facts" || record.reason === "offensive") {21 await queueForReview(record); // an upsert: one queue item per product and prompt version22 }23 res.json({ ok: true });24}Register it in server/app.ts with app.post("/api/feedback", feedbackRoute). Like every ShopLens route, it validates first, so a bad body gets a 400 without touching the database. Then it adds the prompt version and the time. Section 5 puts SUMMARY_PROMPT_VERSION into the summary cache key, so a summary served today was written by today's prompt.
Treat text as untrusted: a script can post anything. The review queue shows it as plain text, never as HTML, and the reviewer checks it against the real summary before believing it. saveFeedback and queueForReview are two more functions in server/data.ts; make queueForReview an upsert keyed by product and prompt version, so fifty reports on one product become one item with a count of fifty. The /api rate limit from section 5 covers this route, which matters because each report adds work for a person. A failed write reaches the error handler, and the bar ignores its 502.
Feedback is only useful if it goes somewhere. At ShopLens, every "Wrong facts" or "Offensive" report adds the product to that review queue. Once a week an engineer reads the queue, and confirmed errors become cases in the 200-product test set that every new prompt must pass. Without that loop, the thumbs are decoration.
Check your understanding
0 of 3 answered
1.Shoppers press Regenerate several times and get nearly identical summaries. What is the best change?
2.Why must compare's custom aspects be limited to 30 characters?
3.Why does the feedback request include the summary text itself?