Lifecycle States
What a generated component looks like when its data is old or incomplete, there's nothing to show, its offer has ended, a newer version replaced it, or the call failed.
Example: Lifecycle States
Pull last week's ad performance again.
Active ads, last 7 days
Generated UI stays in the conversation long after it was useful. A hotel card from yesterday still shows yesterday's prices, and a table pulled this morning may already be wrong. The Renderer handles these states once, for every component, so no component has to.
The states
| State | When | What the user sees | Can they act? |
|---|---|---|---|
streaming | The agent is still generating props | The component's own loading state | No (components disable actions) |
ready | Normal | The component | Yes |
empty | The tool returned nothing (empty: "No overdue invoices") | An empty state with that sentence, plus Check again when onRefresh is set | Refresh only |
partial | Some data is missing (partial: "1 of 4 accounts didn't respond") | A notice above the component | Yes |
failed | The tool call errored (status: "failed") | A short error card with the reason, plus Try again when onRetry is set | Retry only |
cancelled | The user stopped the reply while this call was still streaming (status: "cancelled") | "Stopped before it finished. Nothing from this part was used." plus Try again when onRetry is set | Retry only |
stale | fetchedAt is older than staleAfterMs | "Data from 3 hours ago. It may have changed." plus Refresh when onRefresh is set | Yes |
expired | expiresAt has passed | "This expired 2 hours ago. Ask for an update before acting on it." The component is dimmed and inert | No |
superseded | A newer call replaced it (supersededBy) | "Replaced by a newer version below." with a link to the latest. Dimmed and inert | No |
A call that already has a result (the user acted) is a finished record: it never shows as stale, expired or superseded.
Usage
<GenUIRenderer
call={{
id: part.toolCallId,
name: "book_hotel",
props: part.input,
status: "ready",
fetchedAt: offer.quotedAt, // when the prices were fetched
staleAfterMs: 15 * 60_000, // show "may have changed" after 15 min
expiresAt: offer.validUntil, // turn the card off when the quote ends
}}
catalog={catalog}
onResult={sendToAgent}
onRefresh={(call) => askAgent(`Refresh ${call.name}`)}
/>When the user presses Stop, a tool call that was mid-stream never finishes: with the AI SDK its part stays input-streaming forever, and a half-drawn component would sit in the conversation. Pass the calls through markCancelled after you stop the stream, and every call that was still streaming shows "Stopped" instead. Finished calls are left alone.
import { markCancelled } from "@/components/genui/genui-renderer"
const calls = stopped ? markCancelled(allCalls) : allCallsMark replaced cards with markSuperseded, per tool, so two different tables in one reply don't replace each other:
const calls = markSuperseded(allCalls, { names: ["book_hotel", "propose_changes"] })With the AI SDK, fromAISDK maps an output-error tool part to status: "failed" and copies its errorText into error.
Rules for hosts and agents
- Set
expiresAton anything bookable or priced: hotel and car offers, cart prices, seats, showtimes. An expired offer must not be actionable. - Set
fetchedAtandstaleAfterMson live data: balances, stock quotes, flight status, tables pulled from a tool. - Supersede instead of stacking: when the agent revises a proposal, the old card turns inert and links to the new one.
- Empty is an answer, not an error: say what was checked ("No overdue invoices. Everything due this month is paid.") instead of rendering an empty table.
- Partial results say what's missing so the user doesn't mistake them for the full picture.
- Failed calls say what happened in plain words and whether anything changed ("nothing was changed").
Accessibility
- The notice is a
role="status"line above the component; the failure card isrole="alert". - Expired and superseded components use
inert, so they can't be focused or clicked by mistake; the notice explains why. - Time-based states start after mount (the server renders the plain component), so server and client markup match.