When to Use
- Use when: building a chat interface that streams LLM responses
- Use when: an AI response should render structured React components, not just text
- Use when: tool calls need visual feedback as they execute
- Do NOT use for: non-streaming AI calls (use
generateTextfor one-shot) - Do NOT use for: non-React environments — AI SDK React hooks are React-specific
useChat — Chat Interface
// app/chat/page.tsx
'use client';
import { useChat } from 'ai/react';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
api: '/api/chat',
onError: (err) => console.error('Chat error:', err),
});
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(m => (
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`rounded-2xl px-4 py-2 max-w-[80%] ${
m.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'
}`}>
{m.content}
</div>
</div>
))}
{/* Streaming indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-2xl px-4 py-2">
<span className="flex gap-1">
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
)}
</div>
<form className="p-4 border-t flex gap-2">
<input
value={input}
placeholder="Type a message..."
className="flex-1 rounded-full border px-4 py-2"
disabled={isLoading}
/>
{isLoading
? <button type="button"
: <button type="submit">Send</button>
}
</form>
{error && <div className="text-destructive p-2">{error.message}</div>}
</div>
);
}
Route Handler — streamText
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { streamText, tool } from 'ai';
import { z } from 'zod';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: anthropic('claude-sonnet-4-6'),
system: 'You are a helpful assistant.',
messages,
tools: {
getWeather: tool({
description: 'Get current weather for a city',
parameters: z.object({
city: z.string().describe('City name'),
}),
execute: async ({ city }) => {
const weather = await fetchWeather(city); // real API call
return weather;
},
}),
},
maxSteps: 5, // allow tool calls + follow-up responses
onFinish({ usage, finishReason }) {
console.log({ usage, finishReason }); // log token usage
},
});
return result.toDataStreamResponse();
}
Tool Call UI — Show Progress
// Render tool calls as they happen
{messages.map(m => (
<div key={m.id}>
{m.role === 'assistant' && m.parts?.map((part, i) => {
if (part.type === 'text') {
return <p key={i}>{part.text}</p>;
}
if (part.type === 'tool-invocation') {
return (
<div key={i} className="rounded-lg border p-3 my-2 text-sm">
{part.toolInvocation.state === 'call' && (
<div className="flex items-center gap-2 text-muted-foreground">
<Spinner className="w-4 h-4" />
Calling {part.toolInvocation.toolName}...
</div>
)}
{part.toolInvocation.state === 'result' && (
<div>
<span className="text-green-500">✓</span> {part.toolInvocation.toolName}
<pre className="mt-1 text-xs opacity-70">
{JSON.stringify(part.toolInvocation.result, null, 2)}
</pre>
</div>
)}
</div>
);
}
})}
</div>
))}
Generative UI — Render Components from AI
// Return React components as part of the stream (RSC-based)
// app/api/chat/route.ts — using createStreamableUI
import { createStreamableUI, createStreamableValue } from 'ai/rsc';
export async function submitMessage(input: string) {
'use server';
const uiStream = createStreamableUI(<Spinner />);
(async () => {
const result = await generateText({ model: anthropic('claude-sonnet-4-6'), prompt: input });
if (result.text.includes('weather')) {
uiStream.update(<WeatherCard data={await getWeather()} />);
} else {
uiStream.update(<p>{result.text}</p>);
}
uiStream.done();
})();
return { ui: uiStream.value };
}
Performance Budget for AI UI
Streaming first token: < 500ms (use fastest model for interactive chat)
Full response display: progressive — user sees text immediately, no blank wait
Tool call indicator: visible within 50ms of tool invocation
Error state: shown within 200ms of API error
Input disabled: while streaming (prevent double-submit)
Stop button: always visible while isLoading = true
Anti-Fake-Pass Rules
Before claiming AI chat UI is production-ready, you MUST show:
-
isLoadingdrives both spinner and disabled input — no "submit while streaming" -
stop()button available during streaming — user can cancel long responses - Errors surfaced in UI — not just
console.error - Tool calls show progress state (
call→result) — not silent black box -
maxDurationset on route handler — no indefinitely hanging requests -
usagelogged server-side — token costs are observable
Reference: gates/anti-fake-pass-gate.md