Building AI-Powered User Interfaces in React: Streaming Responses, Tool Call Rendering, and Progressive Enhancement with the Vercel AI SDK
A production-focused guide to building AI chat and copilot interfaces in React. Covers streaming token rendering with useChat and useCompletion, real-time tool call rendering, message persistence, progressive enhancement when AI is unavailable, and performance patterns for high-frequency re-renders.
Building a text box that calls an LLM and prints the response is a weekend project. Building a chat interface that streams tokens in real-time, renders tool calls as they arrive, handles every failure mode gracefully, and does not melt under the re-render pressure of 30 state updates per second is a production engineering problem.
This article covers the full stack of concerns you face when building AI chat and copilot interfaces in React: how the Vercel AI SDK’s hooks work under the hood, how to render tool calls and their results progressively, how to handle loading and error states properly, message persistence patterns, progressive enhancement when the AI backend is unavailable, and how to prevent streaming from hammering your component tree.
How Streaming Hooks Work
The Vercel AI SDK ships two primary hooks for the frontend: useChat for multi-turn conversation interfaces and useCompletion for single-shot text completion (think inline copilot, summarization, content generation).
Both hooks consume a streaming HTTP response using the AI SDK’s protocol format. The server sends newline-delimited chunks encoding token text, tool call invocations, tool results, finish reasons, and usage metadata. The hook deserializes each chunk as it arrives and updates React state incrementally.
// app/api/chat/route.ts — server side
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages,
tools: {
getWeather: {
description: "Get the current weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
required: ["location"],
},
execute: async ({ location }) => {
// Real implementation would call a weather API
return { temperature: 22, condition: "partly cloudy", location };
},
},
},
});
return result.toDataStreamResponse();
}
// components/chat.tsx — client side
"use client";
import { useChat } from "@ai-sdk/react";
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat({ api: "/api/chat" });
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto space-y-4 p-4">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</div>
{error && (
<div className="px-4 py-2 text-sm text-red-600 bg-red-50">
Something went wrong. Please try again.
</div>
)}
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 rounded border px-3 py-2"
/>
<button
type="submit"
disabled={isLoading || input.trim().length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isLoading ? "Thinking..." : "Send"}
</button>
</div>
</form>
</div>
);
}
The messages array contains the full conversation history. Each message includes a role (user, assistant, tool), content (a string or structured parts array), and when the model is streaming, the last assistant message accumulates token text in real-time.
The key thing to understand about isLoading: it is true from the moment you submit until the stream closes, covering both the initial API call latency and the token streaming phase. If you want to distinguish “waiting for first token” from “streaming,” you need to check whether the last message has any content yet.
Rendering Tool Calls in Real-Time
Tool calls are where AI interfaces get genuinely complex. A model might call a tool mid-response, pause, receive the result, and continue generating text. If you render this naively, you get a blank pause in the middle of the response. The better pattern is to render tool call invocations as they arrive and show a loading state while the tool executes.
The AI SDK represents message content as an array of parts rather than a plain string when tools are involved. Each part has a type: text, tool-invocation, or tool-result.
// components/message-bubble.tsx
import type { Message } from "@ai-sdk/react";
interface Props {
message: Message;
}
export function MessageBubble({ message }: Props) {
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="bg-blue-600 text-white rounded-lg px-4 py-2 max-w-prose">
{message.content}
</div>
</div>
);
}
// Assistant messages may have mixed content: text and tool calls
if (message.role === "assistant") {
const parts = message.content;
// If content is a plain string (no tools), render directly
if (typeof parts === "string") {
return (
<div className="prose max-w-prose">
{parts || <span className="text-gray-400 animate-pulse">...</span>}
</div>
);
}
// Structured parts: text interleaved with tool invocations
return (
<div className="space-y-3 max-w-prose">
{parts.map((part, index) => {
if (part.type === "text") {
return (
<div key={index} className="prose">
{part.text}
</div>
);
}
if (part.type === "tool-invocation") {
return (
<ToolCallBlock
key={index}
toolName={part.toolInvocation.toolName}
args={part.toolInvocation.args}
state={part.toolInvocation.state}
result={
part.toolInvocation.state === "result"
? part.toolInvocation.result
: undefined
}
/>
);
}
return null;
})}
</div>
);
}
return null;
}
// components/tool-call-block.tsx
interface Props {
toolName: string;
args: Record<string, unknown>;
state: "call" | "partial-call" | "result";
result?: unknown;
}
export function ToolCallBlock({ toolName, args, state, result }: Props) {
return (
<div className="border rounded-lg p-3 bg-gray-50 text-sm font-mono">
<div className="flex items-center gap-2 mb-2">
<span className="text-gray-500">Tool:</span>
<span className="font-semibold">{toolName}</span>
{state !== "result" && (
<span className="ml-auto text-blue-500 animate-pulse">running...</span>
)}
{state === "result" && (
<span className="ml-auto text-green-600">done</span>
)}
</div>
<div className="text-gray-600">
<span className="text-gray-400">args: </span>
{JSON.stringify(args, null, 2)}
</div>
{state === "result" && result !== undefined && (
<div className="mt-2 pt-2 border-t text-gray-600">
<span className="text-gray-400">result: </span>
{typeof result === "object"
? JSON.stringify(result, null, 2)
: String(result)}
</div>
)}
</div>
);
}
The state field on a tool invocation transitions from partial-call (args still streaming) to call (args complete, tool executing) to result (execution finished). Showing this state progression prevents the “frozen” feeling where the interface appears to stop responding while a tool runs.
Handling Loading, Error, and Empty States
The hooks give you isLoading and error. You need a few more conditions to handle the full state space:
// A more complete state model
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, reload, stop } =
useChat({ api: "/api/chat" });
const isEmpty = messages.length === 0;
const lastMessage = messages[messages.length - 1];
const isStreaming =
isLoading &&
lastMessage?.role === "assistant" &&
typeof lastMessage.content === "string" &&
lastMessage.content.length > 0;
const isWaiting = isLoading && !isStreaming;
return (
<div className="flex flex-col h-full">
{isEmpty && !isLoading && (
<div className="flex-1 flex items-center justify-center text-gray-400">
<p>Start a conversation to get help.</p>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-4 p-4">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isWaiting && (
<div className="flex items-center gap-2 text-gray-400 text-sm">
<span className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-bounce [animation-delay:300ms]" />
</span>
<span>Thinking</span>
</div>
)}
</div>
{error && (
<div className="px-4 py-3 bg-red-50 border-t border-red-100 flex items-center justify-between">
<span className="text-sm text-red-700">
{error.message || "Request failed. Check your connection and try again."}
</span>
<button
onClick={() => reload()}
className="text-sm text-red-700 underline ml-4"
>
Retry
</button>
</div>
)}
<form onSubmit={handleSubmit} className="p-4 border-t flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 rounded border px-3 py-2"
/>
{isLoading ? (
<button
type="button"
onClick={() => stop()}
className="px-4 py-2 bg-red-100 text-red-700 rounded"
>
Stop
</button>
) : (
<button
type="submit"
disabled={input.trim().length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Send
</button>
)}
</form>
</div>
);
}
The stop() function aborts the current stream. This is a required control: users will start a response and realize they asked the wrong question. Providing no stop mechanism is a product quality failure, not a nice-to-have.
Message Persistence Patterns
By default, useChat holds messages in component state. They disappear on page refresh. For any real product you need persistence, but the approach depends on your requirements.
Client-side persistence is the simplest approach. Store messages in localStorage and pass them as the initialMessages option:
import { useChat } from "@ai-sdk/react";
import { useEffect, useState } from "react";
const STORAGE_KEY = "chat-messages-v1";
export function PersistentChat({ conversationId }: { conversationId: string }) {
const storageKey = `${STORAGE_KEY}-${conversationId}`;
const [initialMessages] = useState(() => {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const { messages, ...chatProps } = useChat({
api: "/api/chat",
initialMessages,
id: conversationId,
});
// Persist on every message update
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem(storageKey, JSON.stringify(messages));
}
}, [messages, storageKey]);
return <ChatUI messages={messages} {...chatProps} />;
}
Server-side persistence requires a different shape: store messages in a database keyed by conversation ID, load them on mount, and write them after each exchange. The AI SDK’s onFinish callback fires when the full response (including tool calls) has completed, making it a reliable write point:
const { messages } = useChat({
api: "/api/chat",
id: conversationId,
initialMessages: initialMessagesFromServer,
onFinish: async (message) => {
// The full assistant message is available here, after streaming ends
await fetch(`/api/conversations/${conversationId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
},
});
Do not write during streaming. Write once at the end via onFinish. Streaming produces many intermediate states that are not worth persisting and would create race conditions if you tried to update the database on every token.
Progressive Enhancement When AI Is Unavailable
AI features are optional in most products. If the LLM endpoint is down, rate-limited, or simply not relevant for a given user tier, the interface should degrade gracefully rather than showing a broken screen.
The cleanest pattern is feature detection with a local fallback mode. Check availability on the server during page load and pass a flag to the client:
// app/dashboard/page.tsx
import { ChatInterface } from "@/components/chat-interface";
async function isAIAvailable(): Promise<boolean> {
// Check: does the user have a plan that includes AI? Is the API key set?
// Is the feature flag enabled? A simple synchronous check.
return (
process.env.OPENAI_API_KEY !== undefined &&
process.env.AI_FEATURE_ENABLED === "true"
);
}
export default async function DashboardPage() {
const aiEnabled = await isAIAvailable();
return <ChatInterface aiEnabled={aiEnabled} />;
}
// components/chat-interface.tsx
"use client";
import { useChat } from "@ai-sdk/react";
interface Props {
aiEnabled: boolean;
}
export function ChatInterface({ aiEnabled }: Props) {
if (!aiEnabled) {
return (
<div className="p-6 rounded-lg border bg-gray-50">
<p className="text-sm text-gray-600">
AI assistant is not available on your current plan.
</p>
</div>
);
}
return <ActiveChatUI />;
}
For transient failures (API is temporarily unavailable during a session), use the error state from useChat combined with a fallback suggestion:
{error && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-800">
The AI assistant is temporarily unavailable. Try the{" "}
<a href="/search" className="underline">search page</a> instead.
</p>
</div>
)}
Progressive enhancement is not just about graceful degradation at the component level. It is about making sure the AI feature is genuinely additive: the core product works without it. If removing the chat widget leaves users with no way to accomplish their goal, you have not built a feature, you have built a dependency.
Performance Optimization for High-Frequency Re-Renders
A fast model streaming at 50 tokens per second produces 50 state updates per second. Each update calls setMessages inside the hook, which by default causes every component that reads from messages to re-render. In a large chat interface with many messages, this is expensive.
The primary mitigation is isolation: only the component rendering the actively streaming message should re-render on every token. All historical messages should be stable.
// Stable historical messages: use React.memo to prevent re-renders
import { memo } from "react";
import type { Message } from "@ai-sdk/react";
const HistoricalMessageBubble = memo(function HistoricalMessageBubble({
message,
}: {
message: Message;
}) {
return <MessageBubble message={message} />;
});
// Streaming message: always re-renders
function StreamingMessageBubble({ message }: { message: Message }) {
return <MessageBubble message={message} />;
}
export function MessageList({ messages }: { messages: Message[] }) {
const lastIndex = messages.length - 1;
return (
<div className="space-y-4">
{messages.map((message, index) => {
const isLast = index === lastIndex;
const isAssistant = message.role === "assistant";
// Only the last assistant message can be actively streaming
if (isLast && isAssistant) {
return <StreamingMessageBubble key={message.id} message={message} />;
}
return <HistoricalMessageBubble key={message.id} message={message} />;
})}
</div>
);
}
The second issue is scroll behavior during streaming. Auto-scrolling to the bottom while content streams in requires care: scrolling on every token update while the user might be scrolling up to read older messages is disorienting. Track whether the user is near the bottom and only auto-scroll if they are:
import { useEffect, useRef } from "react";
import type { Message } from "@ai-sdk/react";
export function useAutoScroll(messages: Message[], isLoading: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// "Near bottom" = within 100px of the bottom edge
isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container || !isNearBottomRef.current) return;
container.scrollTop = container.scrollHeight;
}, [messages, isLoading]);
return containerRef;
}
For content rendering, avoid parsing markdown on every token update. Buffer the markdown render: either use a debounced transform or render raw text during streaming and apply full markdown parsing only when the message is complete (its id matches a finished message). Libraries like react-markdown are not expensive for small strings, but they do add overhead when called 50 times per second on long responses.
Tradeoffs
| Dimension | Custom fetch + SSE | Vercel AI SDK | LangChain.js frontend | Custom WebSocket |
|---|---|---|---|---|
| Setup time | High: manual stream parsing, state management, retry logic | Low: hooks handle everything | Medium: requires LangChain server setup | High: connection management, reconnect, protocol |
| Protocol flexibility | Full control | AI SDK wire format only | LangChain protocol | Full control |
| Tool call support | Manual implementation | First-class, typed | First-class (LangChain tools) | Manual implementation |
| Bundle size | Minimal | ~40KB (gzipped) | Large (LangChain adds substantial weight) | Minimal |
| Vendor coupling | None | Vercel / AI SDK protocol | LangChain ecosystem | None |
| Multi-model support | Manual | Built-in (OpenAI, Anthropic, Google, etc.) | Built-in | Manual |
| Streaming reliability | Depends on implementation | Solid: handles reconnect, backpressure | Good | Depends on implementation |
| Message persistence | Manual | Manual (hooks are stateless) | Manual | Manual |
| Real-time bidirectional | No (SSE is unidirectional) | No | No | Yes |
| Best for | Full protocol control, custom models | Standard chat / copilot UIs | LangChain-heavy backends | Live collaboration, presence, gaming |
The AI SDK wins on time-to-production for standard chat interfaces. It becomes a liability when your backend is not compatible with its wire format or when you are building something that genuinely needs bidirectional real-time communication (multiplayer, live editing). In those cases, a custom WebSocket connection with your own message protocol will serve you better.
The LangChain.js frontend path makes sense only if your backend is already deep in the LangChain ecosystem. Its bundle size cost is significant for frontend use.
Production Considerations
Rate limiting and quota management. LLM APIs have rate limits at the token and request level. Implement rate limiting on your API route before it reaches the model, not just in error handling after the model rejects the request. A simple in-memory counter with a Redis-backed fallback is sufficient for most deployments. Surface quota exhaustion as a distinct error state on the client, separate from generic failures.
// app/api/chat/route.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "1 m"), // 20 requests per minute per user
});
export async function POST(req: Request) {
const userId = getUserId(req); // extract from session/JWT
const { success, remaining } = await ratelimit.limit(userId);
if (!success) {
return new Response(
JSON.stringify({ error: "Rate limit exceeded. Try again in a moment." }),
{ status: 429, headers: { "Content-Type": "application/json" } }
);
}
// ... streamText call
}
Input validation and prompt injection defense. User input going directly into a system prompt without sanitization is a security problem. At minimum, trim and length-limit all user input on the server. For copilots with access to user data or tool execution, treat the input as untrusted and enforce scope via system prompt constraints, not just tool design.
Handling partial failures in tool calls. Tools can fail. When they do, the model receives a tool error result and typically continues generating a response acknowledging the failure. You should design your tool execute functions to return structured errors rather than throwing, so the model has something to work with:
execute: async ({ location }) => {
try {
const weather = await weatherApi.fetch(location);
return { success: true, temperature: weather.temp, condition: weather.desc };
} catch (err) {
// Return error as data so the model can acknowledge it gracefully
return { success: false, error: "Weather data unavailable for this location." };
}
},
Connection reliability. Streaming over HTTP has a single failure mode: the connection drops mid-stream. The AI SDK does not automatically retry a dropped stream because the request has already been sent and potentially partially processed. The useChat hook’s error state catches this, but it does not distinguish “stream dropped” from “API error.” For production deployments, log both server-side (when the response stream closes unexpectedly) and client-side (error event on the fetch) to understand your actual reliability profile.
Observability. Instrument your AI routes with token counts and latency at minimum. Most AI SDKs expose usage in the stream finish event. Logging model, input tokens, output tokens, tool calls made, and latency per request gives you the data needed to optimize cost and debug latency regressions before users report them.
const result = streamText({
model: openai("gpt-4o"),
messages,
onFinish: ({ usage, finishReason }) => {
logger.info("chat_completion", {
model: "gpt-4o",
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
finishReason,
userId,
});
},
});
Closing
The mechanics of streaming a response into a React component are straightforward once you stop fighting the re-render frequency. The harder problems are architectural: what happens when the model calls a tool and the network drops, what the user sees when the API is rate-limited, and whether your component tree can absorb 50 state updates per second without making the browser’s main thread unresponsive.
The AI SDK removes a significant amount of low-level streaming boilerplate. What it does not remove is the product thinking required to handle every state the interface can be in, especially the failure states that never appear in demos but happen constantly in production.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.