Building AI Features in React

Feature 1: The Sentiment Badge


The sentiment badge looks like the easiest feature: one word per review. The hard decisions are about volume. The Kestrel X2 has 214 reviews, shown 20 per page, and each page view could trigger up to 20 model calls. Get the volume design wrong and the smallest feature becomes the most expensive one.

This lesson builds the badge end to end: the route, the hook and the component. It ends with the question every senior engineer should ask about a feature like this: should it run in the browser's request path at all?

Classifying one page of 20 reviewsOne call per review• 20 requests per page• 9,000 input tokens• Hits rate limits quickly• A bad reply costs one labelOne call per page• 1 request per page• 3,300 input tokens• About 1.5 seconds• A bad reply costs 20 labels
Batching cuts input by 63 percent — and because review text never changes, labelling once at write time cuts cost a thousandfold.

One call per page, not one per review

The badge prompt from section 2 is about 300 tokens of rules and examples. A review is about 150 tokens. Compare two designs for one page of 20 reviews:

DesignCallsInput tokensTypical timeCost per page
One call per review2020 × (300 + 150) = 9,0000.8 s each, if not rate limitedabout $0.010
One call per page1300 + 20 × 150 = 3,300about 1.5 sabout $0.004

Batching cuts input tokens by 63 percent and requests by a factor of 20. It is also kinder to rate limits: 20 parallel calls from every page view hit a provider's requests-per-minute limit quickly.

Batching has costs too. If the batched reply is invalid, you lose all 20 labels instead of one. And the slowest item in the batch sets the time for all of them. For short labels those costs are small, so ShopLens batches. For long outputs per item, smaller batches of 5 are a reasonable middle ground.

The route

TypeScript
// server/routes/sentiment.tsimport type { Request, Response } from "express";import { SentimentReply, SentimentRequest } from "../../shared/schemas";import { completeJson } from "../completeJson";import { getReviewsByIds } from "../data";import { SENTIMENT_SYSTEM, sentimentUserPrompt } from "../prompts";export async function sentimentRoute(req: Request, res: Response): Promise<void> {  const input = SentimentRequest.safeParse(req.body);  if (!input.success) {    res.status(400).json({ error: "Invalid request" });    return;  }  const reviews = await getReviewsByIds(input.data.productId, input.data.reviewIds);  if (reviews.length === 0) {    res.json({ items: [] });    return;  }  const reply = await completeJson(    { tier: "fast", system: SENTIMENT_SYSTEM, user: sentimentUserPrompt(reviews),      maxTokens: 600, signal: AbortSignal.timeout(10_000) },    SentimentReply,  );  if (!reply) {    res.status(502).json({ error: "Sentiment unavailable" });    return;  }  // Keep one label per review that was actually asked about.  const asked = new Set(reviews.map((r) => r.id));  const items: SentimentReply["items"] = [];  for (const item of reply.items) {    if (asked.has(item.id)) {      items.push(item);      asked.delete(item.id);    }  }  res.json({ items });}

Read it from top to bottom. The request is validated before anything else, so a list of 500 ids is rejected with a 400. The reviews come from the database, filtered by product, so a caller cannot ask about another product's reviews through this product's page. The model call has a 10-second timeout from AbortSignal.timeout, because a badge that arrives after 10 seconds is worthless. Finally, the loop keeps only labels for ids that were requested, and only the first label for each. The schema cannot do that check, because it does not know which ids were asked for.

The hook

TypeScript
// src/hooks/useSentiment.tsimport { useEffect, useState } from "react";import { postJson } from "../api/client";import { SentimentReply, type SentimentLabel } from "../../shared/schemas";export function useSentiment(productId: string, reviewIds: string[]) {  const [labels, setLabels] = useState<Record<string, SentimentLabel>>({});  const key = reviewIds.join(","); // stable dependency: the array is new on every render  useEffect(() => {    const ids = key ? key.split(",") : [];    if (ids.length === 0) return;    const controller = new AbortController();    postJson("/api/sentiment", { productId, reviewIds: ids }, SentimentReply, controller.signal)      .then((reply) => {        setLabels((prev) => {          const next = { ...prev };          for (const item of reply.items) next[item.id] = item.label;          return next;        });      })      .catch((err: unknown) => {        if (!controller.signal.aborted) console.warn("sentiment unavailable", err);      });    return () => controller.abort(); // page changed or component unmounted  }, [productId, key]);  return labels;}

The key string is the most important line. reviewIds is a new array on every render, so using it as an effect dependency would fire a new request on every render, which is the cost bug from section 1. A joined string only changes when the ids change. The cleanup aborts the request when the shopper moves to the next page, so a slow reply for page 1 cannot arrive after page 2 is showing. Failures are logged and otherwise ignored: the badge is decoration, and a missing badge is a fine outcome.

The badge

TSX
// src/components/SentimentBadge.tsximport type { SentimentLabel } from "../../shared/schemas";const TEXT: Record<SentimentLabel, string> = {  positive: "Positive",  mixed: "Mixed",  negative: "Negative",};export function SentimentBadge({ label }: { label: SentimentLabel | undefined }) {  if (!label) return <span className="badge badge--empty" aria-hidden="true" />;  return (    <span className={`badge badge--${label}`}>      <span className="visually-hidden">Review sentiment: </span>      {TEXT[label]}    </span>  );}

In ReviewList, the page calls const labels = useSentiment(productId, page.map((r) => r.id)) and renders <SentimentBadge label={labels[r.id]} /> in each card. Two details make it robust. The empty badge has the same width as a real one, so when labels arrive 1.5 seconds later nothing shifts, and your layout-shift score stays clean. And TEXT is a Record keyed by the label type, so if someone adds a fourth label to the Zod enum, TypeScript refuses to compile until the badge knows how to show it.

Or compute it once, at write time

Here is the honest question. A review's text never changes after it is posted, so its sentiment never changes either. Why classify it on every page view?

Put numbers on it. With 50,000 product page views a day and one badge call per view, the cost is 50,000 × $0.004 = $200 a day. If instead a background job labels each new review once when it is posted, and the store receives 1,000 reviews a day, the cost is 1,000 × $0.0002 = $0.20 a day, and the badge appears instantly with the page.

So in production, ShopLens stores the label in a database column and the page reads it with the reviews. The route you just built stays useful for reviews that have not been labelled yet, and the same pattern of batch, validate, and ignore failures applies to any feature that depends on something the user just did, which cannot be computed in advance. The rule to remember: if the input is fixed, compute the output once.

Check your understanding

0 of 3 answered

1.The hook uses reviewIds.join(",") as an effect dependency instead of reviewIds. Why?

2.The route keeps only items whose id was in the request. What problem does that solve?

3.The store has 50,000 page views and 1,000 new reviews a day. What is the best design for review sentiment?