Skip to main content

React GenUI Implementation Patterns

Why this chapter matters

Architecture becomes concrete at the integration boundary. A team may understand GenUI conceptually and still ship a fragile system because React state, streaming events, tool results, server rendering, and component registration are not designed as one model.

The current ecosystem shows several practical patterns: Vercel AI SDK renders UI from model/tool interaction, AI SDK UI connects message parts to React components, LangSmith/LangGraph can emit structured UI alongside agent streams, assistant-ui registers renderers for LangGraph UI messages, and CopilotKit positions GenUI around agent-driven interaction.

This chapter turns those examples into implementation guidance.

Core mental model

React GenUI is a message/rendering pipeline:

  1. User intent enters a chat, command bar, form, or agent surface.
  2. Backend/agent runtime interprets intent and may call tools.
  3. The runtime emits structured messages or UI events.
  4. The client maps message parts to registered React components.
  5. User interaction with those components sends events back to the workflow.
  6. The workflow updates or removes UI messages as state changes.

The React component is not the agent. It is a typed renderer for a workflow state.

Pattern taxonomy

PatternExample ecosystem signalUse when
Tool result renderingAI SDK UI docs show tool/message parts rendered as componentsYou want rich UI for known tool outputs
Server-component streamingVercel AI SDK 3 introduced generative UI with React Server ComponentsYou are in a Next.js/RSC architecture and want reduced client bundle cost
Agent UI messagesLangSmith/LangGraph docs emit structured UI through streamsYou have stateful graph workflows that need UI alongside messages
Runtime component registryassistant-ui registers UI components on runtime hooksYou want a frontend-owned renderer map for agent-emitted UI
Copilot sidecar/action UICopilotKit-style agent interaction in app contextYou want AI assistance embedded into existing product surfaces

These are not mutually exclusive. A production product may use tool-result rendering for simple features and agent UI messages for long-running workflows.

Component registration pattern

The frontend needs a registry that maps stable names to components:

const genUIComponents = {
WeatherCard,
CustomerRiskPanel,
ApprovalRequest,
SourceList,
DraftEditor,
};

But the registry should not be only a JavaScript object. It should be backed by schema, versioning, and permissions:

Registry concernImplementation rule
Component nameStable, documented, not tied to file path
PropsValidated with Zod, JSON Schema, or equivalent
VersionOld messages still render or degrade gracefully
LoadingComponent supports partial or missing data
ErrorComponent has local fallback
PermissionsSensitive props are not trusted just because they arrived in a message
AnalyticsImpression and action events use stable component ids

Message part rendering

AI SDK-style message rendering commonly separates text from tool or UI parts. The architectural idea is strong even if you use a different framework:

function AssistantMessage({ message }) {
return (
<>
{message.parts.map((part) => {
switch (part.type) {
case "text":
return <MarkdownText key={part.id} value={part.text} />;
case "tool-result":
return <ToolResultRenderer key={part.id} part={part} />;
case "ui":
return <GeneratedComponent key={part.id} payload={part.payload} />;
default:
return <UnsupportedPart key={part.id} />;
}
})}
</>
);
}

The important part is not the exact API. It is that every part has a type, id, and renderer path.

Streaming and replacement semantics

assistant-ui's LangGraph integration highlights a subtle point: UI messages can be keyed by id, and re-emitting the same id can replace an existing entry. That is useful for progress updates, but it can destroy user edits if handled carelessly.

Rules:

  • Agent-owned progress components may be replaced.
  • User-edited components should merge carefully or require explicit regeneration.
  • Removal events should be intentional and logged.
  • Component ids should be stable within a workflow.
  • The reducer should distinguish append, patch, replace, remove, and commit.

Example reducer model:

OperationUse for
appendNew generated component
patchProgress, status, or data update
replaceAgent regenerates a not-yet-edited component
removeTool result or temporary surface is no longer relevant
commitUser approves component output into durable product state

Server Components and client boundaries

Vercel's AI SDK 3 material connects GenUI with React Server Components. This is attractive because it can reduce client JavaScript and let server-side tool/model work produce UI. But the boundary matters.

Use server-rendered/generated UI when:

  • generated content is mostly display or review
  • tool results are server-owned
  • client interactivity is limited or isolated
  • you want lower initial JavaScript cost

Prefer client-owned components when:

  • users edit complex state
  • optimistic interaction matters
  • components need browser APIs
  • workflow must continue offline or across unstable network
  • accessibility focus management depends on live client state

In both cases, the model should not emit arbitrary code. Server Components are an implementation substrate, not a license to skip validation.

LangGraph/LangSmith integration pattern

LangSmith's generative UI React docs describe emitting structured UI components alongside agent messages, using streaming state and component configuration. Architecturally, this maps well to graph workflows:

  • graph node produces or updates a UI message
  • frontend stream receives it
  • component registry renders it
  • user action sends command back to graph
  • graph advances, updates, or pauses

Use this when each UI component corresponds to a workflow step: approval request, source review, draft editor, comparison table, or task status.

Performance controls

Dynamic component loading improves flexibility but can hurt performance.

Controls:

  • preload common GenUI components for critical workflows
  • lazy-load rare components with skeletons
  • cap streamed update frequency
  • avoid re-rendering the entire message list for one component update
  • virtualize long conversations or task logs
  • measure INP during streaming
  • snapshot heavy generated tables before applying patches

Testing strategy

TestPurpose
Schema fixture testsInvalid generated props are rejected
Renderer snapshot testsKnown UI payloads render stable output
Stream reducer testsappend/patch/replace/remove behavior is correct
User-edit preservation testsAgent updates do not overwrite user edits
Accessibility testsGenerated components keep labels and focus behavior
Latency testsStreaming and lazy loading do not create unusable delays
Tool failure testsTool-result components degrade clearly

Reference reducer implementation

A generated UI reducer should be boring, deterministic, and heavily tested.

type UIMessage = {
id: string;
type: string;
version: string;
status: "draft" | "user_edited" | "committed" | "failed";
props: unknown;
updatedAt: number;
};

type UIEvent =
| { type: "append"; message: UIMessage }
| { type: "patch"; id: string; patch: Partial<UIMessage> }
| { type: "replace"; id: string; message: UIMessage }
| { type: "remove"; id: string; reason: string }
| { type: "mark_user_edited"; id: string }
| { type: "commit"; id: string };

function generatedUIReducer(state: UIMessage[], event: UIEvent): UIMessage[] {
switch (event.type) {
case "append":
return state.some((item) => item.id === event.message.id)
? state
: [...state, event.message];

case "patch":
return state.map((item) =>
item.id === event.id && item.status !== "user_edited"
? { ...item, ...event.patch, updatedAt: Date.now() }
: item
);

case "replace":
return state.map((item) =>
item.id === event.id && item.status === "draft"
? event.message
: item
);

case "remove":
return state.filter((item) => item.id !== event.id);

case "mark_user_edited":
return state.map((item) =>
item.id === event.id ? { ...item, status: "user_edited" } : item
);

case "commit":
return state.map((item) =>
item.id === event.id ? { ...item, status: "committed" } : item
);
}
}

This reducer intentionally refuses to patch or replace user-edited messages automatically. Your product may need a more nuanced merge model, but accidental overwrites should be impossible by default.

Renderer architecture

Separate validation from rendering.

function GeneratedComponent({ payload }: { payload: unknown }) {
const result = validateGeneratedPayload(payload);

if (!result.ok) {
reportValidationFailure(result.reason, payload);
return <GeneratedUIFallback reason={result.reason} />;
}

const Component = componentRegistry[result.value.type];

if (!Component) {
return <GeneratedUIFallback reason="unsupported_component" />;
}

return (
<GeneratedUIBoundary componentType={result.value.type}>
<Component {...result.value.props} />
</GeneratedUIBoundary>
);
}

GeneratedUIBoundary should handle runtime errors, analytics, and optional isolation. Do not let one generated component crash the whole assistant surface.

Captured fixture testing

Use real captured payloads as fixtures. Synthetic tests miss the strange shapes models produce.

Fixture categories:

  • valid simple component
  • valid complex component
  • unknown component
  • old component version
  • unsupported future version
  • invalid enum
  • unsafe URL
  • missing citation
  • oversize payload
  • malicious rich text
  • patch after user edit
  • remove committed component

Every production validation failure should become a fixture after redaction.

Server action and tool-result pattern

For Next.js/RSC-style systems, keep side effects on the server:

async function submitApproval(input: ApprovalInput) {
"use server";

const user = await requireUser();
const approval = await loadApproval(input.approvalId, user);

assertPayloadHashMatches(approval, input.approvedPayloadHash);
await executeApprovedAction(approval);

return { status: "ok" };
}

Client UI may render the approval surface, but the server verifies user, workflow, approval id, and payload hash. The model and browser do not decide whether a risky action is valid.

Bundle and runtime strategy

Generated UI can accidentally become a bundle-size trap. If every possible component is loaded into the assistant surface, the product pays for flexibility upfront.

StrategyUse when
Eager load core componentsCommon components appear in most workflows
Lazy load rare componentsComponents are heavy or rarely used
Server-render display componentsMostly read-only generated views
Client-render editable componentsUser interaction and local state matter
Route-level split registriesDifferent product areas have different GenUI vocabularies
Capability negotiationClients vary by version or platform

Measure component load time, interaction latency after generated UI appears, and memory growth in long conversations.

Anti-patterns

  • Treating message text as the source of truth for UI state.
  • Allowing the model to name any React component in the bundle.
  • Updating generated component ids on every stream chunk.
  • Replacing user-edited UI when the agent sends a new version.
  • Lazy-loading every generated component, including the most common ones.
  • Mixing workflow state, chat display state, and product data without ownership rules.

Verification checklist

  • Generated UI messages have stable ids, types, and versions.
  • Component rendering is registry-based and schema-validated.
  • Stream reducer behavior is explicit for append, patch, replace, remove, and commit.
  • User edits are protected from agent overwrites.
  • Server/client boundaries are chosen per component, not by fashion.
  • Dynamic component loading is measured for latency and interaction cost.
  • Component tests use real captured AI/tool payload fixtures.

Exercises

  1. Implement a reducer spec for generated UI messages in one workflow.
  2. Decide which generated components in your product belong on the server and which on the client.
  3. Write a schema for a generated source list component and three invalid payload tests.
  4. Design a replay tool that renders captured generated UI events locally.

Source lens

This chapter is synthesized from the Vercel AI SDK generative UI material, AI SDK UI docs on generative user interfaces, LangSmith/LangGraph generative UI React docs, assistant-ui LangGraph generative UI docs, and CopilotKit's framing of agentic generative UI patterns.