Building AI Features in React

The Contract Your UI Relies On Today


Before ShopLens had any AI, the Kestrel X2 page already showed reviews. The React code was ordinary: fetch /api/products/p-310/reviews, map over the array, render a card per review. It has worked for two years without anyone thinking about it.

It works because of promises that nobody wrote down. The backend team promised a shape, a speed and correct data, and your component was written as if those promises could never be broken. This lesson makes those promises visible, because a model call breaks almost all of them at once, and you cannot defend an assumption you have not noticed.

The promises your components assume without saying soThe reviews endpoint• Same request, same bytes• 120 ms at the 95th percentile• Fails with a status code• True by definitionA model-backed endpoint• Same request, different wording• 1 to 8 seconds, and it varies• Can succeed with the wrong shape• Can be fluent and wrong
A type assertion was safe only because another team kept the promise — nobody keeps it for a model.

Four promises in one fetch

Here is the review list as it exists today:

TSX
// src/components/ReviewList.tsx (before ShopLens)type Review = {  id: string;  rating: 1 | 2 | 3 | 4 | 5;  title: string;  body: string;  helpfulVotes: number;};export function ReviewList({ productId }: { productId: string }) {  const [reviews, setReviews] = useState<Review[] | null>(null);  useEffect(() => {    fetch(`/api/products/${productId}/reviews`)      .then((res) => res.json())      .then((data) => setReviews(data as Review[]));  }, [productId]);  if (!reviews) return <Spinner />;  return (    <ul>      {reviews.map((r) => (        <li key={r.id}>          <Stars value={r.rating} /> <strong>{r.title}</strong>          <p>{r.body}</p>        </li>      ))}    </ul>  );}

Four promises hide in these lines:

  • Typed JSON. res.json() returns valid JSON, and data as Review[] assumes it matches the type.
  • A fixed shape. Every review has the same five fields, every time. r.rating is always a number from 1 to 5.
  • Fast. The response arrives in about 120 ms at the 95th percentile, so a spinner is enough. Nobody needs a cancel button for 120 ms.
  • True. If the API says the rating is 4, the rating is 4. The database is the source of truth, so the UI never shows where a fact came from.

Where your code leans on each promise

Go through the component and mark each line that would misbehave if a promise were broken. This table shows what the ShopLens team found.

PromiseCode that assumes itIf it breaks
Typed JSONres.json() with no catchAn unhandled rejection; the spinner spins forever
Fixed shapedata as Review[], r.rating passed to StarsStars receives undefined or "4 stars" and renders nothing or crashes
Fixed shapekey={r.id}Duplicate or missing keys; React mixes up rows
FastOnly a spinner, no cancelA 10-second wait feels broken; the user clicks away
FastNo check for stale responsesSwitch product quickly and the old product's reviews appear
TrueNo source, no hedging in the UIA wrong statement is shown with the full authority of your brand

Look at the "stale responses" row. It is a real bug even today: if a user switches from product A to product B and A's slow response arrives last, the list shows A's reviews under B's title. With a 120 ms API, this almost never happens, so nobody fixed it. With a 4-second model call, it happens every time someone clicks quickly.

A type is a promise someone else keeps

TypeScript types disappear when the code runs. The line data as Review[] does not check anything. It tells the compiler, "trust me". That is safe only because another team keeps the promise: they own the database schema, they have contract tests, and a breaking change goes through code review.

A model has no such team. It produces a string, and the only thing that decides whether the string matches your type is the string itself. So with model output, the type must be checked at runtime, at the boundary where the data enters your code. In section 2 you will do this with Zod, a library that describes a shape once and gives you both a runtime check and a TypeScript type from the same definition.

Determinism is why caching and testing were easy

Because the reviews endpoint is deterministic, three things come for free. HTTP caching is safe, because a cached copy is the same as a fresh one. Snapshot tests work, because the rendered output is the same every run. And bugs reproduce, because the same click gives the same data.

With a model, each of these needs deliberate work. You cache on purpose, knowing the cached answer is only one of many possible answers. You test your UI against recorded model outputs, not live calls. And you log the exact model output that caused a bug, because you cannot reproduce it by asking again.

The reviews endpoint

  • Same request, same bytes
  • 120 ms at p95
  • Fails with a status code
  • Data is true by definition

A model-backed endpoint

  • Same request, different wording
  • 1 to 8 seconds, and it varies
  • Can "succeed" with the wrong shape
  • Can be fluent and wrong

None of this means the model is bad. It means the model is a different kind of dependency: closer to a human writer than to a database. You would not render a stranger's free text into your layout without checking it. Treat model output the same way.

Check your understanding

0 of 3 answered

1.Why is data as Review[] acceptable for your reviews endpoint but not for model output?

2.A user clicks product A, then quickly product B. With a 4-second AI call, the panel shows A's result under B's title. Which promise was the code relying on?

3.Which statement about caching model output is most accurate?