Course Content
Building AI Features in React
5 sections · 21 lessons
Feature 3: Compare Two Products as a Table
Below the summary, the shopper can pick a second product, for example the Wren Air, and see how buyers compare the two. The output is a table of up to six rows, generated from 25 reviews of each product. It is the slowest ShopLens feature, about 5 seconds, and the one where a wrong answer is most tempting to trust, because a table looks like data.
This lesson builds the route, the hook and the table, using the JSON shape and prompt from section 2.
The route
1// server/routes/compare.ts2import type { Request, Response } from "express";3import { CompareReply, CompareRequest, type CompareRow, type Review } from "../../shared/schemas";4import { completeJson } from "../completeJson";5import { getProduct, getTopReviews } from "../data";6import { COMPARE_SYSTEM, compareUserPrompt } from "../prompts";78export async function compareRoute(req: Request, res: Response): Promise<void> {9 const input = CompareRequest.safeParse(req.body);10 if (!input.success) { res.status(400).json({ error: "Invalid request" }); return; }11 const [idA, idB] = input.data.productIds;12 if (idA === idB) { res.status(400).json({ error: "Pick two different products" }); return; }1314 const [a, b, reviewsA, reviewsB] = await Promise.all([15 getProduct(idA), getProduct(idB), getTopReviews(idA, 25), getTopReviews(idB, 25),16 ]);17 if (!a || !b) { res.status(404).json({ error: "Unknown product" }); return; }1819 const clientGone = new AbortController();20 res.on("close", () => clientGone.abort());21 const reply = await completeJson({22 tier: "main", system: COMPARE_SYSTEM, maxTokens: 800,23 user: compareUserPrompt({ name: a.name, reviews: reviewsA }, { name: b.name, reviews: reviewsB },24 input.data.aspects),25 signal: AbortSignal.any([clientGone.signal, AbortSignal.timeout(20_000)]),26 }, CompareReply);27 if (!reply) { res.status(502).json({ error: "Comparison unavailable" }); return; }2829 res.json(checkEvidence(reply, [...reviewsA, ...reviewsB]));30}3132function checkEvidence(reply: CompareReply, reviews: Review[]): CompareReply {33 const known = new Set(reviews.map((r) => r.id));34 return {35 rows: reply.rows.map((row) => {36 const evidence = row.evidence.filter((id) => known.has(id));37 // A verdict with no real evidence behind it is not a verdict.38 const better: CompareRow["better"] = evidence.length === 0 ? "unclear" : row.better;39 return { ...row, evidence, better };40 }),41 };42}AbortSignal.any combines two reasons to stop into one signal: the shopper disconnected, or 20 seconds passed. Both are available in Node 20. If the model call throws for either reason, the error handler from app.ts answers, or quietly does nothing if the client is gone.
checkEvidence is the meaning check from section 2. It removes review ids that were not in the input, such as an invented r9999, and then applies a rule that no schema can express: a row that claims a winner but has no real evidence is downgraded to "unclear". This is a small line of code with a large effect on honesty. The model is often confident; the table should only be confident when it can point at reviews.
The request also costs more than the others. Fifty reviews of about 90 tokens each, plus the prompt, is about 4,750 input tokens: $0.024 on the larger model. Six rows of output add about 340 tokens, or $0.0085. So one comparison costs about three cents, and there are far more possible product pairs than products, which makes caching harder than for the summary. Section 5 caches comparisons by the sorted pair of ids, so "Kestrel X2 against Wren Air" and "Wren Air against Kestrel X2" share one entry, and only generates pairs that shoppers actually ask for.
The hook
1// src/hooks/useCompare.ts2import { useEffect, useState } from "react";3import { ApiError, postJson } from "../api/client";4import { CompareReply, type CompareRequest } from "../../shared/schemas";56export type CompareState =7 | { status: "idle" }8 | { status: "loading" }9 | { status: "done"; reply: CompareReply }10 | { status: "error"; message: string };1112export function useCompare(productId: string, otherId: string | null): CompareState {13 const [state, setState] = useState<CompareState>({ status: "idle" });1415 useEffect(() => {16 if (!otherId) { setState({ status: "idle" }); return; }17 const controller = new AbortController();18 setState({ status: "loading" });19 const body: CompareRequest = { productIds: [productId, otherId] };2021 postJson("/api/compare", body, CompareReply, controller.signal)22 .then((reply) => setState({ status: "done", reply }))23 .catch((err: unknown) => {24 if (controller.signal.aborted) return; // replaced by a newer choice25 setState({ status: "error", message: err instanceof ApiError ? err.message : "Comparison failed" });26 });2728 return () => controller.abort();29 }, [productId, otherId]);3031 return state;32}Compare this with the summary hook. Here the request is driven entirely by an effect, so the effect's cleanup is the stale-request guard: when the shopper changes the second product from Wren Air to another model, React runs the cleanup, the old request is aborted, and its catch returns early. No ref is needed. When requests start from user events instead, as with the summary's Start again button, you need the explicit ownership check from the previous lesson.
Typing the body as CompareRequest means that if the request schema changes, this call site stops compiling. The browser and server share the contract in both directions.
The table
1// src/components/CompareTable.tsx2import type { CompareReply, CompareRow, Product } from "../../shared/schemas";34export function CompareTable({ a, b, reply }: { a: Product; b: Product; reply: CompareReply }) {5 return (6 <table className="compare">7 <caption>What buyers say: {a.name} compared with {b.name}</caption>8 <thead>9 <tr><th scope="col">Aspect</th><th scope="col">{a.name}</th><th scope="col">{b.name}</th></tr>10 </thead>11 <tbody>12 {reply.rows.map((row, i) => (13 <tr key={`${i}-${row.aspect}`}>14 <th scope="row">15 {row.aspect}16 {row.better === "unclear" && <span className="compare__note">Buyers disagree</span>}17 </th>18 <Cell text={row.a} better={row.better === "a"} name={a.name} />19 <Cell text={row.b} better={row.better === "b"} name={b.name} />20 </tr>21 ))}22 </tbody>23 </table>24 );25}2627function Cell({ text, better, name }: { text: CompareRow["a"]; better: boolean; name: string }) {28 return (29 <td className={better ? "compare__cell compare__cell--better" : "compare__cell"}>30 {text}31 {better && <span className="visually-hidden"> ({name} rated better by buyers)</span>}32 </td>33 );34}Notice the React key: the template string ${i}-${row.aspect}, which combines the row index with the aspect, not just row.aspect. The model can repeat an aspect, and duplicate keys make React confuse rows. Output from a model is never a safe unique key on its own; this is the "fixed shape" promise from section 1 in a new place.
The table uses real table markup: a <caption>, column headers with scope="col", and the aspect as a row header with scope="row". A screen reader user moving across a row hears "Battery, Kestrel X2, 20 plus hours, praised". The winning cell is marked by a style and by visually hidden text, so the meaning never depends on colour alone.
Designing the 5-second wait
Five seconds is long enough that a spinner looks broken. The ShopLens panel shows a skeleton with the same structure as the result: a caption line, a header row with the two product names, which are already known, and four grey rows. Under it, a line of text states what is happening: "Reading 50 reviews of both products…". The skeleton reserves the table's height, so nothing jumps when the result arrives.
You could stream the table row by row using JSON lines, and for tables above 10 seconds that is worth it. At 5 seconds, the ShopLens team chose the skeleton, because partial tables invited shoppers to read a winner before the full picture arrived. Section 4 returns to this trade-off.
Check your understanding
0 of 3 answered
1.A row says product A is better, but after checkEvidence its evidence list is empty. What does ShopLens show?
2.Why does useCompare not need the active.current === controller check that the summary hook uses?
3.Why is the row key built from the index and the aspect, ${i}-${row.aspect}, rather than row.aspect alone?