Course Content
Building AI Features in React
5 sections · 21 lessons
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.
Four promises in one fetch
Here is the review list as it exists today:
1// src/components/ReviewList.tsx (before ShopLens)2type Review = {3 id: string;4 rating: 1 | 2 | 3 | 4 | 5;5 title: string;6 body: string;7 helpfulVotes: number;8};910export function ReviewList({ productId }: { productId: string }) {11 const [reviews, setReviews] = useState<Review[] | null>(null);1213 useEffect(() => {14 fetch(`/api/products/${productId}/reviews`)15 .then((res) => res.json())16 .then((data) => setReviews(data as Review[]));17 }, [productId]);1819 if (!reviews) return <Spinner />;20 return (21 <ul>22 {reviews.map((r) => (23 <li key={r.id}>24 <Stars value={r.rating} /> <strong>{r.title}</strong>25 <p>{r.body}</p>26 </li>27 ))}28 </ul>29 );30}Four promises hide in these lines:
- Typed JSON.
res.json()returns valid JSON, anddata as Review[]assumes it matches the type. - A fixed shape. Every review has the same five fields, every time.
r.ratingis 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.
| Promise | Code that assumes it | If it breaks |
|---|---|---|
| Typed JSON | res.json() with no catch | An unhandled rejection; the spinner spins forever |
| Fixed shape | data as Review[], r.rating passed to Stars | Stars receives undefined or "4 stars" and renders nothing or crashes |
| Fixed shape | key={r.id} | Duplicate or missing keys; React mixes up rows |
| Fast | Only a spinner, no cancel | A 10-second wait feels broken; the user clicks away |
| Fast | No check for stale responses | Switch product quickly and the old product's reviews appear |
| True | No source, no hedging in the UI | A 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?