Client Consumption (useChat)
The full useChat capability matrix / choosing among the four transports / UIMessage vs ModelMessage bridging / what the four statuses actually mean / callback contracts / client-side tool execution / the two-sided resume protocol / 10 common traps
Why this page exists
The first four pages are all about the send side — how streamText runs the step loop, how createUIMessageStream
orchestrates the UI stream, how message chains assemble. But after the stream is out the door, how the receiving side
turns it into React state and renders it in front of the user is an entirely separate topic.
The official AI SDK answer is @ai-sdk/react’s useChat hook — the most common receiving-side implementation. One line
of code to hook into a backend and you get:
messages(the accumulated conversation state)status(the four-state stream machine)- Action methods like
sendMessage/regenerate/stop error+ error-recovery hooks
This page opens the internal model of useChat. It’s not a black box — once you understand the trio AbstractChat +
ChatTransport + ChatStatus, diagnosing “why is status stuck on submitted”, “why are my messages still there after
onError”, and “why isn’t the tool call rendering” becomes direct.
You don’t have to use it. If you already have solid server-state management (React Query, Redux, Zustand…), rolling your own receiving side is perfectly viable. The Case study at the end of this page shows a production choice that skips useChat, along with the trade-offs.
At a glance
| Key number | Value |
|---|---|
Pinned @ai-sdk/react version | 3.0.136 |
Pinned ai core version | 6.0.134 |
ChatStatus count | 4 (submitted / streaming / ready / error) |
Built-in ChatTransport implementations | 3 (DefaultChatTransport / TextStreamChatTransport / DirectChatTransport) |
| Tool UI states | 6 (input-streaming / input-available / approval-requested / approval-responded / output-available / output-error) |
“Did not complete” flags in onFinish | 3 (isAbort / isDisconnect / isError) |
| Default API endpoint | /api/chat (overridable) |
Mapping to the send side:
The useChat capability matrix
useChat is the React binding around AbstractChat. Return shape:
type UseChatHelpers<UI_MESSAGE> = {
readonly id: string; // Chat session id (randomized if options.id is not explicitly passed)
messages: UI_MESSAGE[]; // Accumulated conversation state
status: ChatStatus; // submitted / streaming / ready / error
error: Error | undefined; // Most recent error
sendMessage: (...) => Promise<void>; // Send a user message, triggers the LLM call
regenerate: (...) => Promise<void>; // Regenerate the specified (or last) assistant message
stop: () => Promise<void>; // Abort the in-flight stream
resumeStream: (...) => Promise<void>; // Reconnect to the current stream after a disconnect
addToolOutput: (...) => void; // Asynchronously push a client-side tool result
addToolResult: (...) => void; // @deprecated, use addToolOutput
addToolApprovalResponse: (...) => void; // Push a tool approval response
clearError: () => void; // Clear error state, return to ready
setMessages: (msgs) => void; // Locally edit messages (does not trigger a request)
};
Config options (UseChatOptions → ChatInit):
| Option | Type | Purpose |
|---|---|---|
id | string | Chat session id; the key that multiple hook instances share state through |
transport | ChatTransport | Transport implementation; defaults to DefaultChatTransport (HTTP POST /api/chat) |
messages | UI_MESSAGE[] | Initial messages (for SSR hydration, loading history from DB) |
onFinish | (opts) => void | Called when the stream completes successfully |
onError | (error) => void | Called on any error |
onToolCall | (opts) => void | Called when a tool call arrives; can sync-return a result |
onData | (dataPart) => void | Called for every data-* event |
sendAutomaticallyWhen | (opts) => boolean | Whether to auto-regenerate after a tool output is written |
generateId | () => string | Custom id generator (for stable ids in tests) |
experimental_throttle | number | Re-render throttle (ms); default off |
resume | boolean | Automatically reconnectToStream on mount |
useChat vs useCompletion vs useObject
@ai-sdk/react ships three scenario-specific hooks. They are not a new/old replacement — each owns a different
scenario:
useChat | useCompletion | experimental_useObject | |
|---|---|---|---|
| Scenario | Multi-turn conversation (messages array) | Single-shot text completion (prompt → text) | Single-shot structured output (prompt → schema-shaped JSON) |
| Backend API | streamText().toUIMessageStreamResponse() | streamText().toTextStreamResponse() | streamObject({ schema }).toTextStreamResponse() |
| Input | sendMessage({ text }) | complete(prompt) | submit(input) (input is the JSON body) |
| State | messages[] + four-state status | completion string + isLoading boolean | object: DeepPartial<T> (progressively filled) + isLoading |
| Schema | None (free text) | None (free text) | Required (Zod / JSON Schema — client validation + type inference) |
| Tools | Supports tool call state machine | Not supported | Not supported |
| Streaming granularity | Part-level (text-delta / tool-input-delta / …) | Token-level string append | Field-level (DeepPartial — each field goes undefined → partial string → complete) |
| Built-in input state | No (manage it yourself) | Yes (input / handleInputChange / handleSubmit) | No (you decide when to call submit) |
| Stability | Stable | Stable | experimental_ prefix — signature may change |
How to pick:
- Multi-turn / agent →
useChat - One question, one answer, no history, no tools (writing assistants, code translators, summarizers) →
useCompletion - Generate one shaped object and render it as fields stream in (form fill, card templates, itineraries, recipes) →
experimental_useObject. The key value is thatDeepPartiallets you render intermediate states (“dish name arrived, first three ingredients arrived, steps still undefined”) instead of waiting for the whole JSON — a noticeable UX win for “I can see the model thinking”.
Choose one of four transports
The ChatTransport interface has just two methods:
interface ChatTransport<UI_MESSAGE> {
sendMessages(options): Promise<ReadableStream<UIMessageChunk>>;
reconnectToStream(options): Promise<ReadableStream<UIMessageChunk> | null>;
}
Three built-in implementations + a custom fallback:
| Transport | Protocol | Use for | Typical backend |
|---|---|---|---|
DefaultChatTransport | HTTP POST + SSE | The most common, browser ↔ server full stack | Next.js Route Handler / Hono / Express |
TextStreamChatTransport | HTTP POST + plain text stream | Plain text only, no UI-message protocol | Legacy code / non-AI-SDK backends |
DirectChatTransport | In-process | No HTTP, calls Agent directly | SSR / testing / single-process Electron |
| Custom | Anything | WebSocket / IPC / cross-process | When you need bidirectional, long-lived, or non-HTTP protocols |
DefaultChatTransport config
useChat({
transport: new DefaultChatTransport({
api: "/api/chat", // Default is /api/chat; change here to re-target
credentials: "include", // Send cookies (cross-origin auth)
headers: { Authorization: ... },
body: { sessionId: "xxx" }, // Extra fields sent to the backend (merged into request body)
prepareSendMessagesRequest: ({ messages, ... }) => ({ // Fully override the body
body: convertToMyFormat(messages),
}),
fetch: customFetch, // Intercept / mock
}),
});
body vs prepareSendMessagesRequest:
bodyappends — original{ messages, ... }plus your fieldsprepareSendMessagesRequestfully replaces — only your returned body is sent (for tests or when backend schema is special)
DirectChatTransport — skip HTTP
useChat({
transport: new DirectChatTransport({
agent: myAgent, // Pass an Agent instance directly
options: { model, ... },
}),
});
agent.stream()’s output does not go over HTTP — it’s piped to useChat in the same JS process. Fits:
- SSR / RSC pre-render needing a one-shot completion
- Jest / Vitest testing UI without a real server
- Electron: agent runs in main, renderer consumes
Limitation: reconnectToStream returns null (there’s no concept of disconnect within one process).
When to roll your own
All three built-ins are HTTP-based (DirectChatTransport skips HTTP but has a fixed structure). Three scenarios require a custom transport:
- WebSocket — long-lived connections, bidirectional messages, resource broadcast (multi-tab chat sync)
- IPC (Electron) — renderer ↔ main, where the agent runs on Node
- Cross-process custom protocols (Electron IPC / Chrome extension messaging / ServiceWorker postMessage)
When rolling your own, implement both methods — if you don’t use reconnectToStream, return
Promise.resolve(null). The inner stream must be a ReadableStream<UIMessageChunk>, and chunk types must match what
the server emits (see UI Stream Orchestration).
UIMessage vs ModelMessage — the two most confused types
These are completely different types in the SDK; mixing them fails silently:
UIMessage | ModelMessage | |
|---|---|---|
| From | @ai-sdk/react / ai’s UI message protocol | ai’s model-invocation protocol |
| Client | useChat’s messages are these | Client almost never touches these |
| Server | The HTTP body receives these | What you pass to streamText({ messages }) |
| Shape | { id, role, metadata, parts: UIMessagePart[] } | { role, content: string | ContentPart[] } |
| Contains | text / reasoning / tool (with state) / data-* / file / source / step-start | text / image / file / tool-call / tool-result |
| Designed for | UI consumption: accumulation, retries, tool state machine, custom events | Model calls: prompt format given to the LLM |
Bridge function: convertToModelMessages(uiMessages) (in the ai package, returns Promise<ModelMessage[]>).
Typical Next.js Route Handler:
// POST /api/chat
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(messages), // ← bridge
tools,
});
return result.toUIMessageStreamResponse(); // Returns a UIMessage stream to the client
}
Common errors:
- Client passes
ModelMessagedirectly tosetMessages→ types don’t match,partsmissing - Server passes
uiMessagesdirectly tostreamText→ streamText takes ModelMessage, not UIMessage - Forgetting to
await convertToModelMessages→ ModelMessage[] becomes a Promise (compiles, blows up at runtime)
The nine UIMessagePart types
Rendering a message on the client means iterating message.parts and dispatching each type:
type prefix | Purpose | Render note |
|---|---|---|
text | Accumulated plain-text delta | Stream .text |
reasoning | Reasoning trace (Anthropic thinking, OpenAI reasoning) | Optionally collapsible |
tool-${name} | Static tool call (schema registered at L1) | Branch by state, see next section |
dynamic-tool | Dynamic tool (MCP / runtime discovery) | Same, but read toolName off part |
source-url / source-document | Citation source | Render a citation card / footnote |
file | File part (image, etc.) | <img> / download link |
data-${string} | Custom event payload (non-transient ones) | Dispatch render by type |
step-start | Step boundary marker | Visual separator / hide |
Two paths for consuming data-*:
- Persisted (transient: false) → land in
message.parts, render when iterating - Transient → do NOT land in parts; intercept via
onDatacallback (see below)
The status state machine
| State | Meaning | Typical UI |
|---|---|---|
submitted | Request is out, waiting for the first chunk | Show “connecting…”; disable input |
streaming | First chunk arrived, stream in progress | Show cursor; enable stop button |
ready | Fully received, next message can be sent | Enable input |
error | Something went wrong | Show error + retry; call clearError() to recover |
The most common “stuck at submitted” issue: the server received the request, but the first chunk hasn’t been flushed yet (LLM still thinking, or server doing time-consuming prep). The hook isn’t broken — the fix is:
- On the server, at the top of
createUIMessageStream({ execute })’s execute, immediatelywriter.write({ type: "data-progress", ... })— a transient event to force the first chunk out quickly - Or push a persisted
data-run-initevent (see UI Stream Orchestration)
Status will flip to streaming within tens of milliseconds — users won’t perceive the delay.
Callback contracts
onFinish({ message, messages, isAbort, isDisconnect, isError, finishReason })
The three most-confused flags:
| Flag | Meaning | Response |
|---|---|---|
isAbort | User called stop() | Preserve partial content, UI marks “aborted” |
isDisconnect | Network disconnect (fetch rejection / connection severed) | Show “network issue, reconnect?” and expose resumeStream() |
isError | Server sent an error chunk, or SDK internal error | Inspect error, show the real cause |
These are mutually exclusive — only one is true per finish. If all three are false, finishReason is the signal to
read (stop / length / tool-calls / content-filter / other).
message vs messages:
message: the single assistant message that just finishedmessages: the complete history array including this message
For persistence you usually only need messages — message is there for easy side effects on the new output (toast,
analytics, etc.).
onError(error)
Key point: after onError fires, the SDK does not automatically roll back messages. The user message you just
pushed is still in state — the user sees “sent, but no response”.
Correct error recovery:
const { messages, setMessages, sendMessage, clearError } = useChat({
onError: (error) => {
toast.error(error.message);
// Optional: roll back the last user message
setMessages((prev) => {
if (prev[prev.length - 1]?.role === "user") {
return prev.slice(0, -1);
}
return prev;
});
// Or don't roll back — keep the input, let the user click "retry" to regenerate
},
});
Two strategies:
- Roll back: for “one-shot operations, failure means it didn’t happen”
- Keep + retry: for “conversational operations, user can edit the input”
onToolCall({ toolCall }) + client-side tools
Scenario: certain tools execute in the browser (read geolocation, read localStorage, invoke a native file picker, call a browser API) — not on the backend.
useChat({
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === "getLocation") {
const position = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
// Sync return — SDK automatically stuffs this into output state
return { lat: position.coords.latitude, lng: position.coords.longitude };
}
},
});
Sync return = simple case, SDK auto-writes the return value into tool output state and auto-triggers the next round.
Async long operations = use addToolOutput to push explicitly:
const { addToolOutput } = useChat({
onToolCall: ({ toolCall }) => {
if (toolCall.toolName === "selectFile") {
// Don't return — just kick off a UI flow
openFilePickerModal((file) => {
addToolOutput({
tool: "selectFile",
toolCallId: toolCall.toolCallId,
output: { fileName: file.name, content: await file.text() },
});
});
}
},
});
onData(dataPart)
Purpose: catch every data-* event pushed by the backend’s writer.write({ type: "data-xxx" }) — including
transient ones.
How this differs from iterating message.parts:
Iterate message.parts | onData callback | |
|---|---|---|
| See transient events? | ❌ | ✅ |
| Fires on every part addition | ❌ (only on re-render) | ✅ (once per event) |
| Fits | Rendering persisted business data | One-shot side effects (toast / progress / analytics) |
Typical usage:
useChat({
onData: (part) => {
if (part.type === "data-progress") {
// transient, does not enter message.parts
setProgress(part.data.percent);
}
if (part.type === "data-toast") {
toast(part.data.message);
}
},
});
sendAutomaticallyWhen({ messages })
Scenario: client-side tool execution finishes — should we auto-regenerate to let the LLM continue?
useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
// ↑ Built-in: if every tool call in the last assistant message has an output, auto-regenerate
});
Without this callback → tool output is written into state and stops there; you have to call regenerate() manually.
With it → execution proceeds automatically, closing the agent loop on the client side.
Client-side tool UI state machine
The lifecycle of a tool call inside message parts:
input-streaming
↓ (parameters delta complete)
input-available
↓
├→ (tool needs human approval, rare)
│ ↓
│ approval-requested → approval-responded
│ ↓
▼
output-available or output-error
You must render UI for every state, or you’ll see:
- No UI for
input-streaming→ user doesn’t see parameters being streamed - No UI for
input-available→ tool is executing, user thinks it froze - No UI for
output-error→ tool errored, user just sees the conversation cut off
Simple render template:
message.parts.map((part, i) => {
if (isToolUIPart(part)) {
switch (part.state) {
case "input-streaming":
return <span key={i}>Generating params...</span>;
case "input-available":
return (
<span key={i}>
Calling {part.type} ({JSON.stringify(part.input)})...
</span>
);
case "output-available":
return <ToolResult key={i} tool={part.type} output={part.output} />;
case "output-error":
return (
<span key={i} className="text-red-500">
Failed: {part.errorText}
</span>
);
}
}
// ... other part types
});
Two tool-part types: ToolUIPart<TOOLS> (static tools with schema registered at L1) and DynamicToolUIPart
(dynamic / MCP tools). The former has part.type === "tool-${name}", the latter has part.type === "dynamic-tool". Use
isToolUIPart / isDynamicToolUIPart to distinguish.
resume / reconnect after disconnect
This is a two-sided protocol; setting resume: true alone is not enough:
Client side (minimal):
useChat({
id: "task-123",
resume: true, // Automatically calls reconnectToStream on mount
});
Server side (the core):
DefaultChatTransport’sreconnectToStreamPOSTs to${api}?chatId=${id}(by default)- The backend must buffer the stream at request time (Redis / memory); when a reconnect request arrives, return the buffered chunks + subsequent chunks
- If the stream is already complete, return
nulland let the client render normally
The implementation pattern wraps createUIMessageStream with a buffering layer (e.g. createUIMessageStreamResponse’s
consumeSseStream + Redis). See the official resumableStream tutorial or build your own.
Common misconceptions:
| Wrong | Actual |
|---|---|
Setting resume: true auto-recovers | ❌ Without a backend buffer, the reconnect request gets null — nothing to recover |
resume works for non-HTTP transports | ❌ DirectChatTransport.reconnectToStream always returns null (in-process has no disconnect concept) |
| Page refresh auto-reconnects | ⚠️ Requires a stable chat id (unset or randomized → refresh changes the id → buffer lookup fails) |
experimental_throttle — don’t forget to set it
Off by default, every chunk triggers a React re-render. A long answer with 1000+ text-deltas will:
- Re-render the whole conversation
- Drop browser frames, jittery scrolling
- Main thread busy,
onToolCalland other async callbacks get delayed
useChat({
experimental_throttle: 50, // 50ms chunk batching, one re-render
});
Rule of thumb:
50(20fps): the recommended default, visually imperceptible100(10fps): low-end devices / mobile16(60fps): high-end devices, silky smooth0or unset: only if you really need every chunk instantly (very rare)
The cost of throttling: text-delta doesn’t stream character by character but in batches. Reading experience is basically the same, performance improves by an order of magnitude.
Common traps
1. Not setting id → new chat session on every remount
useChat(); // ❌ id is randomized, changes on component remount
useChat({ id: chatId }); // ✅ id is stable, history associates correctly
Multiple components (sidebar preview + main chat) wanting to share the same chat state must pass the same id.
2. messages not auto-rolled-back after onError
See the “Callback contracts / onError” section above. The default is keep — paired with a “click retry” UX. To roll
back, call setMessages yourself.
3. convertToModelMessages is async; server must await
// ❌ messages becomes Promise<ModelMessage[]>, compiles but blows up
streamText({ messages: convertToModelMessages(uiMessages) });
// ✅
streamText({ messages: await convertToModelMessages(uiMessages) });
4. Treating isAbort / isDisconnect / isError as a single “failed”
These three mean completely different things (see “Callback contracts / onFinish”). Recovery strategies:
isAbort→ do nothing, user chose thisisDisconnect→ exposeresumeStream()to reconnectisError→ inspecterror, show the real cause
5. Mutating messages directly
messages.push({ ... }); // ❌ React can't see the change, no re-render
messages[0].parts.push({ ... }); // ❌ Same + potentially pollutes SDK internal state
setMessages([...messages, newMsg]); // ✅
6. Forgetting to render input-available / output-error states
Rendering only output-available for tool calls produces “tool is executing → user sees nothing → thinks it froze”. You
must cover all 6 states.
7. Not setting experimental_throttle — frames drop
Long answers produce 1000+ text-deltas, one re-render each blows performance. Set at least 50ms.
8. Not await-ing sendMessage → button loading state is wrong
const handleClick = () => {
sendMessage({ text: input }); // Returns immediately, button loading state is wrong
};
const handleClick = async () => {
setLocalLoading(true);
await sendMessage({ text: input });
setLocalLoading(false);
};
Or use status directly (recommended): status === "submitted" || status === "streaming" means “busy”.
9. Using useCompletion for multi-turn
useCompletion only keeps the most recent completion; history is lost. Multi-turn must use useChat.
10. SSR hydration messages inconsistent
At SSR time useChat doesn’t know the history; after client hydrate it’s also empty → blank flash before fill.
useChat({
id: chatId,
messages: initialMessagesFromServer, // passed in from SSR
});
The messages init param exists specifically for the hydration case.
Case study: when NOT to use useChat
Zapvol’s choice: TanStack React Query + a custom streaming hook, not useChat.
Why not
| Axis | useChat assumption | Zapvol reality |
|---|---|---|
| Chat state lifecycle | ”One conversation = one hook instance” | Task has its own lifecycle (create / run / pause / resume / archive), decoupled from hook mounting |
| Loading history | Pass init messages in | History, run state, metadata, user info are all server state; React Query unifies them |
| Cross-component sharing | Multiple useChats with the same id share | Task list, task detail, preview modal all need the same task state — React Query’s query client is the natural fit |
| Error recovery | Inside the hook | Error state must persist across page nav (from task list to detail, still in error) |
| Protocol customization | DefaultChatTransport is enough | Need SSE resume (Redis-buffered), browser bridge bidirectional, task abort controller management, BUA control, multiple concurrent tasks with independent aborts |
The core decision: an agent run produces not “a conversation” but “a stateful task”. Modeling the task as a React Query server resource and subscribing to its streaming updates via an SSE hook fits better than useChat’s model.
What you give up
Rolling your own in place of useChat means implementing:
- The four-state
statusmachine (React Query’sisPending/isError/ etc. cover most of it, with semantic mapping) experimental_throttle(your own buffer + flush)- Tool call UI state machine (parse
tool-input-start/tool-input-available/ … from theUIMessageChunkstream yourself) onToolCallmechanism (Zapvol’s tools are all server-side — no client tools needed)- The client-side
convertToModelMessagesbridge (Zapvol persists aUIMessage-derived typeZapvolMessagein the backend and callsconvertToModelMessagesitself beforestreamText; it does not route throughuseChat’s built-in bridge path)
Which projects should pick useChat
- Pure chat apps, no task / run concept
- No complex server-state requirements (React Query / SWR not in play)
- Backend is a standard Next.js API Route or Hono, one question one answer
- Shipping speed matters; writing less code beats controlling granularity
This isn’t “useChat is bad”, it’s “does it match the fit” — useChat is the “conversation-centric” abstraction; outside that niche it starts to chafe.
Further reading
Related SDK chapters
- UI Stream Orchestration — the send-side counterpart:
createUIMessageStream/writer/ the data-* event protocol - Runtime Lifecycle — the full timeline (three layers, 12 callbacks)
- Message Reference Model — the SDK-internal model of UIMessage / ModelMessage
SDK source anchors
@ai-sdk/react@3.0.136—dist/index.d.tsline 39 (useChatsignature) / lines 13-37 (UseChatHelpers/UseChatOptions)ai@6.0.134—dist/index.d.tslines 3589-3656 (ChatTransportinterface) / 3719 (ChatStatus) / 3753-3790 (ChatInit) / 3815-3880 (AbstractChat.sendMessageand other methods)ai@6.0.134—dist/index.d.tslines 4004-4007 (DefaultChatTransport) / 4042-4057 (DirectChatTransport) / 4078-4083 (TextStreamChatTransport)
Zapvol landing reference
packages/app/src/hooks/— how a custom streaming hook is organizedpackages/app/src/api/modules/task.ts— the front-end API client that skips useChat