Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~32 min· topic 6 of 7

Topic 7.6

Generative UI

In one line

In generative UI the model does not write the screen. It picks a component by name and fills in its props, from a registry you wrote. Your code looks the name up, validates the props with zod, and renders the real component, or a safe fallback. The model never sends code, and never HTML to run.

Think of it like this

A restaurant with a printed menu and an order slip. The customer cannot ask the kitchen for any dish in the world. They tick items from the menu and fill in the options ("spicy: medium"). The waiter checks the slip: is the dish on the menu, are the options allowed? If not, the waiter says "we don't have that" instead of sending something odd to the kitchen. The menu is your component registry. The model is the customer. The waiter is your validator.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Generative UI
A screen where the model chooses which of your components to show and with what props.
Registry
An object mapping component names to the component and a schema for its props. The only things the model can ask for.
Spec
The data the model returns: a component name and props. Never code.
Envelope
The outer shape of a spec: { component, props }.
Fallback
A safe component shown when a spec is unknown, invalid or crashes.
Object.hasOwn
A check that a key belongs to the object itself, not to the base Object (like constructor).
Error boundary
A React class component that catches render errors in its children and shows a replacement.
Discriminated union
Several object shapes told apart by one field. Here the component field picks the props shape.
Skeleton
A grey placeholder shown in the shape of content that is still loading.

Step by step

01What the model sends

A spec is a tiny piece of JSON. The model has no way to put anything in it that runs.

a specwhole filejson
{ "component": "OrderStatus", "props": { "orderId": "ORD-1042" } }

02Registry and renderer

The first live example has a small registry (Note, ProductCard) and a function toElement(raw) that returns either the real component or the fallback, with a reason for your logs. It runs ten specs. Read the numbers: 1 and 2 are good. 3 asks for a component that is not registered. 4 is the name constructor. 5 and 6 have bad or missing props. 7 contains HTML-looking text, which comes out as escaped text (<b>). 8 has an extra prop, which is dropped. 9 and 10 are not specs at all.

03Real cards with their own states

The second example renders one answer made of eight blocks. OrderStatus fetches its own data from /api/order and validates what comes back (your data can be wrong too: order ORD-1045 has a made-up status). ORD-9999 returns 404. Promo crashes. Gallery is not registered. Read the console: every card logs its own state, and the last line shows the final screen text. Five different outcomes sit next to each other and none of them breaks the rest.

Real cards with their own statesdiagram
Rendering diagram…

04Strict or loose

The third example prints the component names the model is shown when the schema is built from the registry, then sends the same reply with one bad block through a strict and a loose schema. Strict: rejected as a whole. Loose: two cards and one fallback. Choose on purpose which behaviour you want.

Try it yourself

  1. 1

    Add a component

    In the first example add a Price component (({ amount }) => <p>₹{amount}</p>) and register it with z.object({ amount: z.number().min(0).max(100000) }). Add the specs { component: "Price", props: { amount: 240 } } and { component: "Price", props: { amount: "free" } }.

    The first renders; the second falls back. Adding a capability is three lines, and nothing else changes.

  2. 2

    Send the model a longer prop

    Change the Note spec on line 1 so its text has 400 characters ("x".repeat(400)).

    The schema's .max(300) rejects it and the fallback shows. Limits on props protect your layout from a runaway answer.

  3. 3

    Remove the error boundary

    In the second example change <Boundary> and </Boundary> in Generated to a fragment <> and </>.

    The console shows Uncaught TypeError: Cannot read properties of undefined (reading 'length') and no screen: line ever appears: React removed the whole tree, so the page is blank and one buggy card took down all the others. Put the boundary back.

Code & diagrams

Ten specs, some of them hostile

Line 7: the HTML-looking text is shown as text (&lt;b&gt;), never parsed as HTML. Line 8: the onClick prop is not in the schema, so it is stripped. The reasons (the text before =>) are for your log, not for the user.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1 ok => <p class="note">Your order ships in 1 to 2 working days.</p>
2 ok => <section class="product"><h3>Masala chai 250 g</h3><p>Black tea with cardamom, ginger and clove.</p></section>
3 unknown component "Script" => <p class="fallback">Mitra could not show this part of the answer.</p>
4 unknown component "constructor" => <p class="fallback">Mitra could not show this part of the answer.</p>
5 bad props for ProductCard: productId -> Invalid option: expected one of "masala-chai"|"ginger-biscuits" => <p class="fallback">Mitra could not show this part of the answer.</p>
6 bad props for ProductCard: productId -> Invalid option: expected one of "masala-chai"|"ginger-biscuits" => <p class="fallback">Mitra could not show this part of the answer.</p>
7 ok => <p class="note">&lt;b&gt;BANANA&lt;/b&gt; () =&gt; alert(1)</p>
8 ok => <p class="note">Hi</p>
9 not a component spec => <p class="fallback">Mitra could not show this part of the answer.</p>
10 not a component spec => <p class="fallback">Mitra could not show this part of the answer.</p>
Eight blocks, five different outcomes

Every card finds its own way. ORD-9999 finishes first (a 404 is instant), the others in the order their data arrives. The React error text for Promo is React's own log of a caught render error; the boundary replaced only that card.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

TypeError: Cannot read properties of undefined (reading 'length')

The above error occurred in the <Promo> component.

React will try to recreate this component tree from scratch using the error boundary you provided, Boundary.

boundary caught: Cannot read properties of undefined (reading 'length')
OrderStatus ORD-1042: loading
OrderStatus ORD-1043: loading
OrderStatus ORD-1044: loading
OrderStatus ORD-1045: loading
OrderStatus ORD-9999: loading
OrderStatus ORD-9999: error
OrderStatus ORD-1042: ready
OrderStatus ORD-1043: ready
OrderStatus ORD-1044: ready
OrderStatus ORD-1045: error
screen: Here are your orders. ORD-1042: Masala chai 250 g. On the way, arrives in 2 days. ORD-1043: Ginger biscuits. Being packed, arrives in 4 days. ORD-1044: Masala chai 250 g. Cancelled. Order ORD-1045 has unexpected data. We could not find order ORD-9999. Mitra could not show this part of the answer. Mitra could not show this part of the answer.
Strict or loose: one bad block among three

Strict tells the model exactly what it may choose and rejects the whole reply on one mistake. Loose keeps the good parts. Both still check props at render time in a real app.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

component names the model is shown: ["Note","OrderStatus"]
strict, good reply: reply accepted, blocks shown: Note, OrderStatus
strict, one bad block: reply rejected, AI_NoObjectGeneratedError - nothing shown
loose, one bad block: reply accepted, blocks shown: Note, fallback, OrderStatus

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Look the name up with a plain index

In the first example, in toElement, change if (!Object.hasOwn(REGISTRY, component)) to if (!REGISTRY[component]).

terminal
$ Console
── what you'll see ──
1 ok => <p class="note">Your order ships in 1 to 2 working days.</p>
2 ok => <section class="product"><h3>Masala chai 250 g</h3><p>Black tea with cardamom, ginger and clove.</p></section>
3 unknown component "Script" => <p class="fallback">Mitra could not show this part of the answer.</p>
Uncaught TypeError: Cannot read properties of undefined (reading 'safeParse')

Myth vs fact

Myth

Generative UI means the model writes React code.

Fact

In the safe version it only picks a registered component and fills in props. Code or HTML from a model is never run or inserted.

Myth

If the props pass the schema, nothing can go wrong.

Fact

A bug in your own component can still crash on props that pass, so keep an error boundary around each generated component.

Myth

The model should provide the facts on the card.

Fact

Let it choose the card and the id. Load the facts (status, price, stock) from your own data, so the card is true even when the model is wrong.

Myth

An unknown component should throw so we notice.

Fact

Render a harmless fallback for the user and log the unknown name for yourselves. Throwing breaks the page for the user over something they cannot fix.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Keep the registry small and boring. Each entry is something you must test, make accessible and keep working. Ten well-made components beat fifty half-finished ones.

  • ▸

    Give each component's schema the same limits as its layout: string lengths, list sizes, allowed ids. A runaway answer then becomes a fallback, not a broken page.

  • ▸

    Log every fallback with the reason (the first column of the first example). A growing count of unknown component after a model change tells you the model is drifting from your instructions.

  • ▸

    Version the registry with the prompt. If you rename a component, old saved chats containing the old name will render as fallbacks. Keep the old name as an alias or migrate the saved data.

Remember this

  1. 1

    The problem this solves: After Topics 7.1 to 7.5 the model can return an object. In a real assistant the answer is not always the same shape: sometimes it is a sentence, sometimes an order card, sometimes a product, sometimes a list. You want the model to decide *which* piece of interface suits the question, without letting it write interface code. That is generative UI.

  2. 2

    The idea. You keep a registry: a plain object that maps a name ("OrderStatus") to a React component and to a zod schema for its props. The model's reply is a spec, a small piece of data: { "component": "OrderStatus", "props": { "orderId": "ORD-1042" } }. Your code does four things with a spec: check it has the right envelope, look the name up in the registry, validate the props with that component's schema, and render. If any step fails, render a fallback.

  3. 3

    The model never sends code. A spec is data that names something you already wrote. It does not contain JSX, functions, eval, or HTML to insert into the page. This is the whole safety story: the worst a model (or a hostile message that steers it) can do is choose a component from your list, with props that passed your schema. The first example sends a component called Script with a code prop, an HTML string, and a prop named onClick. Nothing runs: the unknown name gets a fallback, the HTML shows as plain text, and the extra prop is stripped. Never ask the model for JSX or HTML, even "to save time".

  4. 4

    Look names up safely. The component name is text from the model, so it can be anything, including constructor or __proto__. If you write REGISTRY[name], the name constructor finds the Object function that every object inherits, so the lookup looks like a hit and your code crashes later with a confusing error. Use Object.hasOwn(REGISTRY, name) (or a Map) so only your real keys match. The first example includes constructor on purpose.

  5. 5

    Two ways to wire the contract. (1) Strict: build the model's schema from the registry (z.discriminatedUnion("component", …)) so the model is *shown* the allowed names and props, and a wrong reply is rejected as a whole by the SDK. (2) Loose envelope: ask for { component: string, props: object } and validate each block when it is rendered. The third example shows the trade-off with one bad block among three: the strict schema rejects the whole reply (nothing shown), the loose one shows the two good blocks and a fallback for the bad one. Strict gives the model better guidance and gives you less to handle. Loose gives users partial answers. Many apps use strict for the model and keep the render-time check as well, because the registry can change between the request and the render.

  6. 6

    A fallback that is useful. A fallback is a normal component with plain words: "Mitra could not show this part of the answer." It does not print the spec, the zod error or the component name to the user. Log those for yourselves. If the whole answer fails, show the text answer, if there is one, instead of an empty screen.

  7. 7

    Loading and error belong to the component. The model should choose *which* card to show, but the *facts* should come from your own data. OrderStatus receives only orderId, fetches the order from your API, and handles its own states: a skeleton while loading, a message if the order does not exist, a message if your own data is malformed. The second example shows five cards with different outcomes next to each other, and one failing card does not stop the others.

  8. 8

    Add an error boundary around each generated component. Props that pass the schema can still hit a bug in your component (the Promo card allows items to be missing and then reads items.length). Without a boundary, one bad card blanks the whole page. A React error boundary catches the render error and shows the fallback for that card only. Keep the registry small, and write each schema to match what the component really needs.

Explain it without notes

01

What does the model send in generative UI, and why is it safe?

02

Why use Object.hasOwn to look up the component name?

03

What is the difference between a strict and a loose schema for specs?

04

Who should supply the facts shown on an OrderStatus card, and why?

05

Why wrap each generated component in an error boundary when props are already validated?

Practice

01

Add a ProductCard that shows a price from your catalog and a Buy button. What should the button do when the product is out of stock, and which part of this must not come from the model?

02

Write renderBlocks(blocks) that returns one element per block, with a stable key, and the fallback for any block that is not an object.

Trade-offs

  • ↔

    A registry gives safety and consistent design at the cost of flexibility: the model can only produce what you built. Start with the three or four cards your users ask for most.

  • ↔

    Strict specs give the model better guidance but reject whole replies; loose specs show partial answers but leave the model guessing. Pick based on whether a half-answer is acceptable in your product.

Done when you can

  • I can explain what a spec is and why the model never sends code.

  • I can write a registry with a component and a zod props schema for each entry.

  • I can look a name up safely with Object.hasOwn and render a fallback for unknown names and bad props.

  • I can give a component its own loading and error states and load its facts from my own data.

  • I can wrap generated components in an error boundary and choose between strict and loose specs.