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:
- User intent enters a chat, command bar, form, or agent surface.
- Backend/agent runtime interprets intent and may call tools.
- The runtime emits structured messages or UI events.
- The client maps message parts to registered React components.
- User interaction with those components sends events back to the workflow.
- 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
| Pattern | Example ecosystem signal | Use when |
|---|---|---|
| Tool result rendering | AI SDK UI docs show tool/message parts rendered as components | You want rich UI for known tool outputs |
| Server-component streaming | Vercel AI SDK 3 introduced generative UI with React Server Components | You are in a Next.js/RSC architecture and want reduced client bundle cost |
| Agent UI messages | LangSmith/LangGraph docs emit structured UI through streams | You have stateful graph workflows that need UI alongside messages |
| Runtime component registry | assistant-ui registers UI components on runtime hooks | You want a frontend-owned renderer map for agent-emitted UI |
| Copilot sidecar/action UI | CopilotKit-style agent interaction in app context | You 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 concern | Implementation rule |
|---|---|
| Component name | Stable, documented, not tied to file path |
| Props | Validated with Zod, JSON Schema, or equivalent |
| Version | Old messages still render or degrade gracefully |
| Loading | Component supports partial or missing data |
| Error | Component has local fallback |
| Permissions | Sensitive props are not trusted just because they arrived in a message |
| Analytics | Impression 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:
| Operation | Use for |
|---|---|
append | New generated component |
patch | Progress, status, or data update |
replace | Agent regenerates a not-yet-edited component |
remove | Tool result or temporary surface is no longer relevant |
commit | User 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
| Test | Purpose |
|---|---|
| Schema fixture tests | Invalid generated props are rejected |
| Renderer snapshot tests | Known UI payloads render stable output |
| Stream reducer tests | append/patch/replace/remove behavior is correct |
| User-edit preservation tests | Agent updates do not overwrite user edits |
| Accessibility tests | Generated components keep labels and focus behavior |
| Latency tests | Streaming and lazy loading do not create unusable delays |
| Tool failure tests | Tool-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.
| Strategy | Use when |
|---|---|
| Eager load core components | Common components appear in most workflows |
| Lazy load rare components | Components are heavy or rarely used |
| Server-render display components | Mostly read-only generated views |
| Client-render editable components | User interaction and local state matter |
| Route-level split registries | Different product areas have different GenUI vocabularies |
| Capability negotiation | Clients 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
- Implement a reducer spec for generated UI messages in one workflow.
- Decide which generated components in your product belong on the server and which on the client.
- Write a schema for a generated source list component and three invalid payload tests.
- 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.