Building AI Features in React

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.

What checkEvidence does to three rowsar1042, r2217abnone: r9999 inventedunclearar0988 kept,r9021 cuta, with a linkModel saysReal evidenceTable showsBatteryNoiseComfortA real id with the opposite meaning survives the check, so evidence is shown as a link to read.
Invented ids are cut and a verdict with no real evidence becomes unclear, however confident the model sounded.

The route

TypeScript
// server/routes/compare.tsimport type { Request, Response } from "express";import { CompareReply, CompareRequest, type CompareRow, type Review } from "../../shared/schemas";import { completeJson } from "../completeJson";import { getProduct, getTopReviews } from "../data";import { COMPARE_SYSTEM, compareUserPrompt } from "../prompts";export async function compareRoute(req: Request, res: Response): Promise<void> {  const input = CompareRequest.safeParse(req.body);  if (!input.success) { res.status(400).json({ error: "Invalid request" }); return; }  const [idA, idB] = input.data.productIds;  if (idA === idB) { res.status(400).json({ error: "Pick two different products" }); return; }  const [a, b, reviewsA, reviewsB] = await Promise.all([    getProduct(idA), getProduct(idB), getTopReviews(idA, 25), getTopReviews(idB, 25),  ]);  if (!a || !b) { res.status(404).json({ error: "Unknown product" }); return; }  const clientGone = new AbortController();  res.on("close", () => clientGone.abort());  const reply = await completeJson({    tier: "main", system: COMPARE_SYSTEM, maxTokens: 800,    user: compareUserPrompt({ name: a.name, reviews: reviewsA }, { name: b.name, reviews: reviewsB },      input.data.aspects),    signal: AbortSignal.any([clientGone.signal, AbortSignal.timeout(20_000)]),  }, CompareReply);  if (!reply) { res.status(502).json({ error: "Comparison unavailable" }); return; }  res.json(checkEvidence(reply, [...reviewsA, ...reviewsB]));}function checkEvidence(reply: CompareReply, reviews: Review[]): CompareReply {  const known = new Set(reviews.map((r) => r.id));  return {    rows: reply.rows.map((row) => {      const evidence = row.evidence.filter((id) => known.has(id));      // A verdict with no real evidence behind it is not a verdict.      const better: CompareRow["better"] = evidence.length === 0 ? "unclear" : row.better;      return { ...row, evidence, better };    }),  };}

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

TypeScript
// src/hooks/useCompare.tsimport { useEffect, useState } from "react";import { ApiError, postJson } from "../api/client";import { CompareReply, type CompareRequest } from "../../shared/schemas";export type CompareState =  | { status: "idle" }  | { status: "loading" }  | { status: "done"; reply: CompareReply }  | { status: "error"; message: string };export function useCompare(productId: string, otherId: string | null): CompareState {  const [state, setState] = useState<CompareState>({ status: "idle" });  useEffect(() => {    if (!otherId) { setState({ status: "idle" }); return; }    const controller = new AbortController();    setState({ status: "loading" });    const body: CompareRequest = { productIds: [productId, otherId] };    postJson("/api/compare", body, CompareReply, controller.signal)      .then((reply) => setState({ status: "done", reply }))      .catch((err: unknown) => {        if (controller.signal.aborted) return; // replaced by a newer choice        setState({ status: "error", message: err instanceof ApiError ? err.message : "Comparison failed" });      });    return () => controller.abort();  }, [productId, otherId]);  return state;}

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

TSX
// src/components/CompareTable.tsximport type { CompareReply, CompareRow, Product } from "../../shared/schemas";export function CompareTable({ a, b, reply }: { a: Product; b: Product; reply: CompareReply }) {  return (    <table className="compare">      <caption>What buyers say: {a.name} compared with {b.name}</caption>      <thead>        <tr><th scope="col">Aspect</th><th scope="col">{a.name}</th><th scope="col">{b.name}</th></tr>      </thead>      <tbody>        {reply.rows.map((row, i) => (          <tr key={`${i}-${row.aspect}`}>            <th scope="row">              {row.aspect}              {row.better === "unclear" && <span className="compare__note">Buyers disagree</span>}            </th>            <Cell text={row.a} better={row.better === "a"} name={a.name} />            <Cell text={row.b} better={row.better === "b"} name={b.name} />          </tr>        ))}      </tbody>    </table>  );}function Cell({ text, better, name }: { text: CompareRow["a"]; better: boolean; name: string }) {  return (    <td className={better ? "compare__cell compare__cell--better" : "compare__cell"}>      {text}      {better && <span className="visually-hidden"> ({name} rated better by buyers)</span>}    </td>  );}

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?