BXUP Track progress & test yourself — free
Frontend & Mobile · Hands-on lab · ~5 min read
FRONTEND & MOBILE · HANDS-ON LAB

Lab 12 · Capstone 3 — Generative UI

Add two tools to the chat surface — weather and order status — whose streamed results render as typed design-system components with loading, partial, and error states per tool part. The pass bar: the part-mapping code compiles fully typed, and every tool state is demonstrable in Storybook or a recording.

Jonas's chat assistant answers "where's my order?" with a paragraph describing the order status. His PM wants what users actually expect: the order card — the same component the orders page uses, data-bound, tappable. That's generative UI, and the architecture matters more than the effect: the model never emits markup or picks components. It emits typed intent — a tool call with arguments — and trusted app code maps that intent to real design-system components. The starter you inherit gets the effect right and the architecture wrong; your job is to type it end to end and give every tool state a designed rendering.

Time & tier

~120 minutes hands-on · Ship stage (maps to module M7, playbook PB-49) · $0 — extends the Lab 10/11 build; mock server emits the tool parts; Storybook for state demos.

Setup

From your chat-lab repo: npm install zod and npx storybook@latest init if not present. Teach the mock server tools — add to server/index.mjs:

const TOOL_SCRIPTS = {
  "weather in berlin": [
    { type: "tool-start", id: "t1", tool: "getWeather", args: { city: "Berlin" } },
    { type: "tool-delta", id: "t1", patch: { tempC: 19, condition: "partly-cloudy" } },
    { type: "tool-delta", id: "t1", patch: { high: 22, low: 13, wind: 14 } },
    { type: "tool-result", id: "t1", status: "ok" },
    { type: "text", delta: "Mild day in Berlin — light jacket weather." },
  ],
  "order 4821": [
    { type: "tool-start", id: "t2", tool: "getOrderStatus", args: { orderId: "4821" } },
    { type: "tool-delta", id: "t2", patch: { status: "shipped", carrier: "DHL" } },
    { type: "tool-delta", id: "t2", patch: { eta: "2026-08-08", items: 3 } },
    { type: "tool-result", id: "t2", status: "ok" },
    { type: "text", delta: "It left the warehouse yesterday — arriving Friday." },
  ],
  "order 9999": [
    { type: "tool-start", id: "t3", tool: "getOrderStatus", args: { orderId: "9999" } },
    { type: "tool-result", id: "t3", status: "error", error: "ORDER_NOT_FOUND" },
    { type: "text", delta: "I couldn't find that order — check the number?" },
  ],
};

app.post("/api/chat2", async (req, res) => {
  const key = (req.body?.prompt ?? "").toLowerCase().trim();
  const script = TOOL_SCRIPTS[key] ?? [{ type: "text", delta: "Try: weather in berlin / order 4821 / order 9999" }];
  res.setHeader("Content-Type", "text/event-stream");
  res.flushHeaders();
  for (const part of script) {
    await new Promise((r) => setTimeout(r, 700)); // slow enough to SEE each state
    res.write(`data: ${JSON.stringify(part)}\n\n`);
  }
  res.write("data: [DONE]\n\n");
  res.end();
});

The starter part-renderer "a teammate generated," src/chat/ToolPart.tsx — commit verbatim, then improve:

export function ToolPart({ part }: { part: any }) {
  const data = part.data as any;
  if (part.tool === "getWeather") {
    return (
      <div className="card">
        <b>{part.args.city}</b>
        <span>{data.tempC}°C, {data.condition}</span>
        <span>H {data.high}° / L {data.low}° · wind {data.wind} km/h</span>
      </div>
    );
  }
  if (part.tool === "getOrderStatus") {
    return (
      <div className="card">
        <b>Order #{part.args.orderId}</b>
        <span>{data.status.toUpperCase()} via {data.carrier}</span>
        <span>ETA {data.eta} · {data.items} items</span>
      </div>
    );
  }
  return null;
}

Enable strictness so the compiler is your reviewer — in tsconfig.json compiler options, ensure "strict": true and add "noImplicitAny": true (Vite's default template has strict on; verify).

Guided steps

  1. Reproduce the starter's failure modes first. Wire /api/chat2 events into your message state (each tool-start appends a tool part; tool-delta shallow-merges patch into that part's data; tool-result sets its status) and render tool parts through the starter ToolPart. Send weather in berlin and watch closely during the 700ms gaps. Expected: a crash or "undefined" renders between tool-start and the first delta (data.tempC on empty data — and data.status.toUpperCase() will throw for the order tool). Then send order 9999. Expected: the error result renders as a broken half-card or nothing — silently. Log both observations in genui-log.md; these are the seeded defects.
  2. Define the contract in Zod, derive the types. Create src/chat/toolSchema.ts: a WeatherData and OrderStatusData schema (all data fields optional — partial by construction, because deltas arrive incrementally), plus a discriminated union ToolPart over { tool: "getWeather", args: {...}, status: "running" | "ok" | "error", data: Partial<...>, error?: string }. Export inferred types (z.infer). Parse every incoming SSE tool event through the schema at the boundary — unknown tools or malformed patches must fail there, loudly, not deep in a component.
  3. Rebuild the renderer as an exhaustive typed map. Replace ToolPart.tsx: a discriminated-union switch on part.tool where each branch receives its precise type — no any, no casts. Add an exhaustiveness check (default: return assertNever(part)) so adding a third tool without a renderer becomes a compile error. Route each branch to a real component: WeatherCard, OrderStatusCard.
  4. Design the three states per tool. Each card component takes { args, status, data, error } and renders: loading (status running, no data yet — skeleton with the args visible: "Checking weather for Berlin…"), partial (running with some fields — show what exists, skeleton the rest; every field access must tolerate absence by type, since data is Partial), and error (designed failure: for ORDER_NOT_FOUND, a card saying so with a "check order number" hint — not an exception, not an empty div). The success state is just partial with everything present plus status: ok.
  5. Prove each state in Storybook.WeatherCard.stories.tsx and OrderStatusCard.stories.tsx with stories per state: Loading, Partial, Complete, Error. The stories pin the states independently of stream timing — this is your visual contract and regression surface. Expected: 8 stories, all rendering distinctly.
  6. Verify the live flow end to end.npx tsc --noEmit must be clean. Run all three prompts and watch each card move through loading → partial → complete (the 700ms cadence makes each state visible to the eye), and order 9999 land in the designed error card while the model's follow-up text still streams after it. Record a short screen capture (or screenshot each phase) into proof/.
  7. Test the contract violation path. Temporarily add a malformed script entry to the server (a tool-delta with patch: { tempC: "warm" }, or an unknown tool: "getStock"). Expected: the Zod boundary rejects it, the UI renders a generic "unsupported tool response" fallback part — and nothing crashes. This is the difference between typing your happy path and typing your boundary. Revert after.
  8. Write the architecture note. Ten lines in genui-log.md: model emits intent → schema validates at the boundary → typed parts → exhaustive component map → design-system cards own their states. Note explicitly what the model is never allowed to do (emit HTML/JSX, name arbitrary components, bypass validation) and why the Partial<> typing is load-bearing for streaming.
Note

The 700ms inter-part delay is pedagogical slow motion. Real tool latency varies from instant to many seconds, which is exactly why every state you just designed will be seen by real users — the loading and partial states aren't edge cases, they're the primary experience of any tool slower than perception.

Verify

  • npx tsc --noEmit clean, with grep -rn "any\|as any" src/chat/ToolPart.tsx src/chat/toolSchema.ts returning no type escapes.
  • Adding a fake third tool to the union without a renderer branch breaks the build (exhaustiveness proven — try it, note it, revert).
  • All 8 Storybook stories render: two tools × loading/partial/complete/error.
  • Live run: all three prompts produce correct card progressions; order 9999 shows the designed error card; follow-up text still streams.
  • Malformed event and unknown tool both hit the fallback part without a crash.
  • genui-log.md contains the step-1 defect observations and the architecture note.

Self-grading rubric (pass = 7/10)

  • Seeded defects reproduced and diagnosed (0–2): both starter failures observed live with mechanism written before fixing = 2.
  • Type integrity (0–3): discriminated union + Zod boundary + exhaustiveness check, zero any = 3.
  • State design (0–3): loading/partial/error all designed (skeletons, field-level tolerance, humane error copy) and pinned in Storybook = 3.
  • Boundary hardening (0–2): step-7 violations handled by fallback, not crash = 2.
▸Answer key — the seeded defects and the expected architectureoptional deep dive — click to expand

The starter ToolPart.tsx seeds two defect families:

  • Seed 1 — untyped, partial-blind rendering.part: any and data as any mean the compiler verifies nothing; and the renderer assumes complete data. Concrete failures you should have observed in step 1: during weather in berlin, the card renders undefined°C, undefined for 700ms (loading gap) and H undefined° after the first delta (partial gap); during order 4821/order 9999, data.status.toUpperCase() throws TypeError: Cannot read properties of undefined the moment the part renders before its first delta — a white-screen crash if you have no error boundary. Root cause: streaming makes every tool part partial for most of its lifetime, so the type of data must be Partial<T> and the renderer must be total over absence. The fix is architectural (schema with optional fields + per-state rendering), not defensive ?. sprinkled through the starter — sprinkled optional chaining earns partial credit only, because it silences the compiler's help instead of using it and still renders "undefined" strings.
  • Seed 2 — error results silently mis-rendered. The starter switches only on part.tool, never on status. The order 9999 script sends tool-result with status: "error" and no data deltas — the starter renders the order branch against empty data (crash per above, or an empty shell if you'd already added chaining) and the ORDER_NOT_FOUND signal is dropped on the floor. Expected fix: status is part of the discriminated state; the error branch renders a designed card carrying error code mapped to humane copy. Grading: full credit requires the error card to preserve the args context ("Order #9999") — an error state that loses what the user asked about fails the design bar.
  • Expected architecture (steps 2–3): Zod schemas with all-optional data fields; a discriminated union on the tool literal; assertNever exhaustiveness so the union and the renderer can't drift; validation at the SSE boundary with a fallback part type for unknown/malformed — the generative-UI security posture in miniature: the model proposes typed intent; only trusted code chooses components. If your implementation lets any string from the stream reach dangerouslySetInnerHTML or select a component by name dynamically (components[part.tool] without a closed map), mark yourself down on boundary hardening — that's the vulnerability class Lab 13 attacks.

Storybook expectations: 8 stories minimum; the Partial stories must show mixed presence (some fields real, some skeleton) — an all-skeleton "partial" is just loading renamed.

Key Takeaway:

Generative UI is a typing discipline wearing a UX costume — the model streams typed intent, a schema boundary makes it trustworthy, an exhaustive map makes it total, and Partial<> everywhere makes streaming honest; the components were never the hard part, the contract was.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in