Building AI Features in React

Failing Gracefully: Fallbacks, Partial Results, Empty States


On a Saturday afternoon, the model provider had a 40-minute partial outage. Most requests failed with server errors; the rest took 30 seconds. On every product page, ShopLens showed "Summary unavailable" in the panel, spinners where badges should be, and "Comparison failed" under the product picker. The pages still worked, because of the error boundaries from section 2, but they looked broken, and the panel took up a third of the screen on a phone to say so.

An AI feature will fail. Providers have outages, rate limits bite at peak hours, and some products have too little data. The question is what the shopper sees when it happens. This lesson designs the failure states for all three features.

What the shopper sees as things go wrongThe full AI summary with citationsPartial text, labelled as cut shortMost helpful reviews, no model neededAn empty state that explains why
Every failure lands on a deliberate screen built from data you already trust, so an outage looks designed rather than broken.

Every AI feature needs a non-AI fallback

The best fallback uses data you already have and trust, and asks nothing of the model.

FeatureFallbackSource
Sentiment badgeNo badge; the stars remainNothing to do
Buyer summary"Most helpful reviews": the first sentence of the top threeReviews table, sorted by helpful votes
Compare tableA specification table: battery hours, weight, price, average starsProduct catalog

These fallbacks are not as good as the AI versions, and that is fine. The shopper still gets useful information in the same space, with the same layout, and the page looks deliberate rather than broken. A fallback also removes pressure on the team during an outage: nobody needs to disable the feature in a hurry.

Partial results: keep, label, continue

A stream that fails after 40 words has produced 40 useful words. Throwing them away to show an error is a waste, and the state machine from section 3 already keeps them as partial. What to show depends on how much arrived:

TSX
// src/components/SummaryFooter.tsx (DoneControls from the previous lesson lives here too)import type { RequestState } from "../state/requestMachine";import { NotEnoughReviews } from "./NotEnoughReviews";import { TopReviewsFallback } from "./TopReviewsFallback";const MIN_USEFUL_WORDS = 12;const words = (s: string) => s.trim().split(/\s+/).filter(Boolean).length;type Props = {  productId: string;  state: RequestState<string>;  onRetry: (options?: { fresh?: boolean }) => void;};export function SummaryFooter({ productId, state, onRetry }: Props) {  if (state.status === "done") return <DoneControls productId={productId} text={state.data} onRetry={onRetry} />;  if (state.status === "cancelled") {    return <p className="muted">Stopped. <button type="button" className="link" onClick={() => onRetry()}>Show the full summary</button></p>;  }  if (state.status !== "error") return null; // idle, loading and streaming need no footer  if (state.message === "not_enough_reviews") return <NotEnoughReviews />;  if (words(state.partial) < MIN_USEFUL_WORDS) return <TopReviewsFallback productId={productId} />;  return (    <p className="muted">      This summary was cut short.{" "}      {state.retryable && <button type="button" className="link" onClick={() => onRetry()}>Try again</button>}    </p>  );}

With 12 words or more, the partial text stays on screen with a plain note: "This summary was cut short." Below 12 words, a fragment like "Most buyers praise the" is more confusing than helpful, so the fallback replaces it. For a stopped summary, the wording is different, because the shopper chose to stop: no apology, just a way to get the rest. The Try again link appears only when the error is retryable, using the flag computed once in ApiError.

In BuyerSummary, when the fallback is showing, the summary paragraph above it should be hidden too, so the shopper does not see a fragment and a fallback together. That is a one-line condition on the same word count.

Empty states that explain

Some products do not have enough reviews to summarise. That is not an error, and it should not look like one. The check happens in the browser before any request: the product page already knows the review count, so a product with fewer than 5 reviews never calls the summary route at all.

TSX
// src/components/NotEnoughReviews.tsxexport function NotEnoughReviews({ count, needed = 5 }: { count?: number; needed?: number }) {  return (    <p className="muted">      Not enough reviews to summarise yet      {count !== undefined && ` (${count} of ${needed} needed)`}. Reviews from buyers appear below.    </p>  );}

The server's not_enough_reviews answer covers the rarer case from section 2, where there are enough reviews but they say nothing about the product and the model returns the NOT_ENOUGH_INFO sentinel. Both paths render the same calm message. An empty state should say why there is nothing, and what the shopper can do instead, in this case read the reviews below.

The same applies to compare. If the Wren Air has only 2 reviews, the panel says "Wren Air has only 2 reviews, so buyers' views cannot be compared yet" and shows the specification table, without calling the model.

Retries the user can see, and ones they cannot

Automatic retries can turn a short outage into a longer one, because every client retrying at once adds load exactly when the provider is struggling. ShopLens follows three rules:

  • JSON features retry once, automatically, for retryable errors only, after about 1 second plus a random jitter of up to 250 ms, so that thousands of browsers do not retry at the same instant. A Retry-After header, if present, replaces the computed wait.
  • Streams never retry automatically once text has appeared. A silent restart would replace the words the shopper is reading. The shopper decides with Try again.
  • Long waits are shown, not hidden. If Retry-After says 20 seconds, the compare panel shows the specification fallback with "Comparison busy, try again in 20 s" and a disabled button that counts down.
TypeScript
// src/api/client.ts (addition)function sleep(ms: number, signal: AbortSignal): Promise<void> {  return new Promise((resolve, reject) => {    const timer = setTimeout(resolve, ms);    signal.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }, { once: true });  });}export async function withRetry<T>(run: () => Promise<T>, signal: AbortSignal, attempts = 2): Promise<T> {  for (let attempt = 1; ; attempt++) {    try {      return await run();    } catch (err) {      const canRetry = err instanceof ApiError && err.retryable && !signal.aborted && attempt < attempts;      const wait = err instanceof ApiError && err.retryAfterMs !== undefined        ? err.retryAfterMs : 1000 * 2 ** (attempt - 1) + Math.random() * 250;      if (!canRetry || wait > 5000) throw err; // long waits go to the UI instead      await sleep(wait, signal);    }  }}

In useCompare, the call becomes withRetry(() => postJson(...), controller.signal). The sleep listens to the same abort signal, so if the shopper picks a different product during the wait, the retry is cancelled too. A wait over 5 seconds is not slept through silently; the error goes to the UI, which can show the countdown.

Check your understanding

0 of 3 answered

1.A summary stream fails after 5 words. What should ShopLens show?

2.Why does ShopLens never retry a stream automatically after text has appeared?

3.During an outage, why add a random jitter to the retry wait?