Course Content
Building AI Features in React
5 sections · 21 lessons
Validate Before It Touches State
The compare prototype had one line that did all the trusting: setRows(JSON.parse(reply).rows). It worked for a week. Then one reply came back as {"comparison": {"rows": [...]}}, one level deeper than expected. rows was undefined, rows.map threw a TypeError during render, and there was no error boundary. The entire product page, including the Add to Cart button, went blank for every shopper who opened the compare panel on that product.
One malformed reply took down the page, not just the panel. That is the cost of letting unchecked data reach React state. This lesson builds the check that ShopLens uses everywhere: Zod schemas at every boundary, a safe parser on the server, and a second check in the browser.
One schema, two uses
Zod lets you describe a shape once and get two things from it: a runtime check that returns either typed data or a list of problems, and a TypeScript type inferred from the same definition. ShopLens keeps these schemas in one folder that both the server and the React app import, so the two sides can never drift apart.
1// shared/schemas.ts (part 1) — imported by server/ and src/2import { z } from "zod";34export const Review = z.object({5 id: z.string(), // e.g. "r1042"6 productId: z.string(), // e.g. "p-310"7 rating: z.number().int().min(1).max(5),8 title: z.string(),9 body: z.string(),10 helpfulVotes: z.number().int().nonnegative(),11});12export type Review = z.infer<typeof Review>;1314export const SentimentLabel = z.enum(["positive", "mixed", "negative"]);15export type SentimentLabel = z.infer<typeof SentimentLabel>;1617export const SentimentRequest = z.object({18 productId: z.string().min(1),19 reviewIds: z.array(z.string()).min(1).max(20),20});21export const SentimentReply = z.object({22 items: z.array(z.object({ id: z.string(), label: SentimentLabel })),23});24export type SentimentReply = z.infer<typeof SentimentReply>;1// shared/schemas.ts (part 2)2export const SummaryRequest = z.object({3 productId: z.string().min(1),4 fresh: z.boolean().optional(), // true skips the cache; used by Regenerate5});67export const Verdict = z.enum(["a", "b", "tie", "unclear"]);8export const CompareRow = z.object({9 aspect: z.string().min(1).max(40),10 a: z.string().max(120),11 b: z.string().max(120),12 better: Verdict,13 evidence: z.array(z.string()).max(4),14});15export type CompareRow = z.infer<typeof CompareRow>;1617export const CompareReply = z.object({ rows: z.array(CompareRow).min(1).max(6) });18export type CompareReply = z.infer<typeof CompareReply>;1920export const CompareRequest = z.object({21 productIds: z.tuple([z.string().min(1), z.string().min(1)]),22 aspects: z.array(z.string().min(1).max(30)).max(5).optional(),23});24export type CompareRequest = z.infer<typeof CompareRequest>;Each schema and its type share a name, such as CompareReply. TypeScript allows this because values and types live in separate namespaces, and it keeps imports short. Notice that the cell limit is 120 characters although the prompt asks for 90. The prompt aims for 90; the schema only rejects replies that would clearly break the layout. A 95-character cell is fine and should not cost a retry.
Parsing model JSON on the server
The server receives a string from the model. Turning it into typed data takes three steps, and each can fail in its own way.
1// server/parseModelJson.ts2import type { ZodType } from "zod";34export type Parsed<T> = { ok: true; data: T } | { ok: false; error: string };56const FENCE = /^`{3}(?:json)?\s*|\s*`{3}$/g; // a Markdown code fence at either end78export function parseModelJson<T>(raw: string, schema: ZodType<T>): Parsed<T> {9 const text = raw.trim().replace(FENCE, "");10 let json: unknown;11 try {12 json = JSON.parse(text);13 } catch {14 return { ok: false, error: "The reply was not valid JSON." };15 }16 const result = schema.safeParse(json);17 if (!result.success) {18 const issues = result.error.issues.map((i) => `${i.path.join(".") || "root"}: ${i.message}`);19 return { ok: false, error: issues.join("; ") };20 }21 return { ok: true, data: result.data };22}First it removes a code fence if the model wrapped its JSON in one, which is common even when told not to. Then JSON.parse runs inside a try, because truncated output throws. Finally safeParse checks the shape and returns either the typed data or a list of issues, each with its path, such as rows.2.better followed by Zod's message about the allowed values. The function never throws, so callers must handle both outcomes.
Repair once, then give up cleanly
When validation fails, one retry that tells the model what was wrong usually fixes it. More than one retry rarely helps and always costs.
1// server/completeJson.ts2import type { ZodType } from "zod";3import { llm, type LlmRequest } from "./llm";4import { parseModelJson } from "./parseModelJson";56export async function completeJson<T>(req: LlmRequest, schema: ZodType<T>): Promise<T | null> {7 let user = req.user;8 for (let attempt = 1; attempt <= 2; attempt++) {9 const raw = await llm.complete({ ...req, user });10 const parsed = parseModelJson(raw, schema);11 if (parsed.ok) return parsed.data;12 console.warn("model JSON invalid", { attempt, error: parsed.error, raw: raw.slice(0, 500) });13 user = `${req.user}\n\nYour previous reply was rejected: ${parsed.error}\nReply again with only the JSON object.`;14 }15 return null; // the route turns this into a 502 and the UI shows its fallback16}The llm client and its LlmRequest type are defined in section 3; for now, read llm.complete as "send this prompt, get text back". A repair doubles the time and cost of that one request. On ShopLens, about 1 in 200 compare calls needs a repair, and about 1 in 5,000 fails both attempts. Those return null, the route answers with a 502, and the UI shows a fallback. The logged raw reply is how you find out why.
Validate again in the browser
If the server already validates, why check again in the browser? Because the browser cannot know what actually arrived. The BFF and the frontend deploy separately, so a new frontend may briefly talk to an old server. A CDN may serve a cached response from an older version. And a load balancer that times out returns an HTML error page, not JSON at all.
So the ShopLens typed client in section 3 takes a schema with every call and refuses to return data that does not match. The rule for the whole app is short: nothing enters React state without passing a schema. Invalid data becomes an error state that the UI knows how to render, never a crash.
Checks that need context
Zod checks shape. Some rules need information that the schema does not have: every evidence id must be one of the reviews sent in this request, and every sentiment item must belong to a review that was asked about. These checks run in the route right after parsing, using the request's own data. You will write them in section 3; they are short, and they catch the model inventing ids like r9999.
Check your understanding
0 of 3 answered
1.The server already validates compare replies with Zod. Why does the browser validate again?
2.A compare reply fails validation. What does ShopLens do?
3.Why is the schema's cell limit 120 characters when the prompt asks for 90?