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 numberValue
Pinned @ai-sdk/react version3.0.136
Pinned ai core version6.0.134
ChatStatus count4 (submitted / streaming / ready / error)
Built-in ChatTransport implementations3 (DefaultChatTransport / TextStreamChatTransport / DirectChatTransport)
Tool UI states6 (input-streaming / input-available / approval-requested / approval-responded / output-available / output-error)
“Did not complete” flags in onFinish3 (isAbort / isDisconnect / isError)
Default API endpoint/api/chat (overridable)

Mapping to the send side:

Send side / Receive side mapping Server (Node / Hono / Next.js) streamText / ToolLoopAgent └─ result.toUIMessageStream() / createUIMessageStream({ execute }) └─ ReadableStream<UIMessageChunk> └─ HTTP SSE response body SSE / WebSocket / IPC Client (React / @ai-sdk/react) ChatTransport.sendMessages() └─ reads ReadableStream<UIMessageChunk> └─ AbstractChat — state machine + parts accumulation └─ useChat() React binding └─ { messages, status, sendMessage, ... }

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 (UseChatOptionsChatInit):

OptionTypePurpose
idstringChat session id; the key that multiple hook instances share state through
transportChatTransportTransport implementation; defaults to DefaultChatTransport (HTTP POST /api/chat)
messagesUI_MESSAGE[]Initial messages (for SSR hydration, loading history from DB)
onFinish(opts) => voidCalled when the stream completes successfully
onError(error) => voidCalled on any error
onToolCall(opts) => voidCalled when a tool call arrives; can sync-return a result
onData(dataPart) => voidCalled for every data-* event
sendAutomaticallyWhen(opts) => booleanWhether to auto-regenerate after a tool output is written
generateId() => stringCustom id generator (for stable ids in tests)
experimental_throttlenumberRe-render throttle (ms); default off
resumebooleanAutomatically 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:

useChatuseCompletionexperimental_useObject
ScenarioMulti-turn conversation (messages array)Single-shot text completion (prompt → text)Single-shot structured output (prompt → schema-shaped JSON)
Backend APIstreamText().toUIMessageStreamResponse()streamText().toTextStreamResponse()streamObject({ schema }).toTextStreamResponse()
InputsendMessage({ text })complete(prompt)submit(input) (input is the JSON body)
Statemessages[] + four-state statuscompletion string + isLoading booleanobject: DeepPartial<T> (progressively filled) + isLoading
SchemaNone (free text)None (free text)Required (Zod / JSON Schema — client validation + type inference)
ToolsSupports tool call state machineNot supportedNot supported
Streaming granularityPart-level (text-delta / tool-input-delta / …)Token-level string appendField-level (DeepPartial — each field goes undefined → partial string → complete)
Built-in input stateNo (manage it yourself)Yes (input / handleInputChange / handleSubmit)No (you decide when to call submit)
StabilityStableStableexperimental_ prefix — signature may change

How to pick:

  • Multi-turn / agentuseChat
  • 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 that DeepPartial lets 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:

TransportProtocolUse forTypical backend
DefaultChatTransportHTTP POST + SSEThe most common, browser ↔ server full stackNext.js Route Handler / Hono / Express
TextStreamChatTransportHTTP POST + plain text streamPlain text only, no UI-message protocolLegacy code / non-AI-SDK backends
DirectChatTransportIn-processNo HTTP, calls Agent directlySSR / testing / single-process Electron
CustomAnythingWebSocket / IPC / cross-processWhen 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:

  • body appends — original { messages, ... } plus your fields
  • prepareSendMessagesRequest fully 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:

  1. WebSocket — long-lived connections, bidirectional messages, resource broadcast (multi-tab chat sync)
  2. IPC (Electron) — renderer ↔ main, where the agent runs on Node
  3. 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:

UIMessageModelMessage
From@ai-sdk/react / ai’s UI message protocolai’s model-invocation protocol
ClientuseChat’s messages are theseClient almost never touches these
ServerThe HTTP body receives theseWhat you pass to streamText({ messages })
Shape{ id, role, metadata, parts: UIMessagePart[] }{ role, content: string | ContentPart[] }
Containstext / reasoning / tool (with state) / data-* / file / source / step-starttext / image / file / tool-call / tool-result
Designed forUI consumption: accumulation, retries, tool state machine, custom eventsModel 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 ModelMessage directly to setMessages → types don’t match, parts missing
  • Server passes uiMessages directly to streamText → 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 prefixPurposeRender note
textAccumulated plain-text deltaStream .text
reasoningReasoning trace (Anthropic thinking, OpenAI reasoning)Optionally collapsible
tool-${name}Static tool call (schema registered at L1)Branch by state, see next section
dynamic-toolDynamic tool (MCP / runtime discovery)Same, but read toolName off part
source-url / source-documentCitation sourceRender a citation card / footnote
fileFile part (image, etc.)<img> / download link
data-${string}Custom event payload (non-transient ones)Dispatch render by type
step-startStep boundary markerVisual 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 onData callback (see below)

The status state machine

ChatStatus state machine 4 states — submitted · streaming · ready · error submitted streaming ready error sendMessage() first chunk arrives finish chunk sendMessage() again error chunk / fetch reject clearError() Source: @ai-sdk/react — ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'

StateMeaningTypical UI
submittedRequest is out, waiting for the first chunkShow “connecting…”; disable input
streamingFirst chunk arrived, stream in progressShow cursor; enable stop button
readyFully received, next message can be sentEnable input
errorSomething went wrongShow 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, immediately writer.write({ type: "data-progress", ... }) — a transient event to force the first chunk out quickly
  • Or push a persisted data-run-init event (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:

FlagMeaningResponse
isAbortUser called stop()Preserve partial content, UI marks “aborted”
isDisconnectNetwork disconnect (fetch rejection / connection severed)Show “network issue, reconnect?” and expose resumeStream()
isErrorServer sent an error chunk, or SDK internal errorInspect 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 finished
  • messages: the complete history array including this message

For persistence you usually only need messagesmessage 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.partsonData callback
See transient events?
Fires on every part addition❌ (only on re-render)✅ (once per event)
FitsRendering persisted business dataOne-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’s reconnectToStream POSTs 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 null and 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:

WrongActual
Setting resume: true auto-recovers❌ Without a backend buffer, the reconnect request gets null — nothing to recover
resume works for non-HTTP transportsDirectChatTransport.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, onToolCall and other async callbacks get delayed
useChat({
  experimental_throttle: 50, // 50ms chunk batching, one re-render
});

Rule of thumb:

  • 50 (20fps): the recommended default, visually imperceptible
  • 100 (10fps): low-end devices / mobile
  • 16 (60fps): high-end devices, silky smooth
  • 0 or 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 this
  • isDisconnect → expose resumeStream() to reconnect
  • isError → inspect error, 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

AxisuseChat assumptionZapvol reality
Chat state lifecycle”One conversation = one hook instance”Task has its own lifecycle (create / run / pause / resume / archive), decoupled from hook mounting
Loading historyPass init messages inHistory, run state, metadata, user info are all server state; React Query unifies them
Cross-component sharingMultiple useChats with the same id shareTask list, task detail, preview modal all need the same task state — React Query’s query client is the natural fit
Error recoveryInside the hookError state must persist across page nav (from task list to detail, still in error)
Protocol customizationDefaultChatTransport is enoughNeed 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 status machine (React Query’s isPending / 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 the UIMessageChunk stream yourself)
  • onToolCall mechanism (Zapvol’s tools are all server-side — no client tools needed)
  • The client-side convertToModelMessages bridge (Zapvol persists a UIMessage-derived type ZapvolMessage in the backend and calls convertToModelMessages itself before streamText; it does not route through useChat’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

SDK source anchors

  • @ai-sdk/react@3.0.136dist/index.d.ts line 39 (useChat signature) / lines 13-37 (UseChatHelpers / UseChatOptions)
  • ai@6.0.134dist/index.d.ts lines 3589-3656 (ChatTransport interface) / 3719 (ChatStatus) / 3753-3790 (ChatInit) / 3815-3880 (AbstractChat.sendMessage and other methods)
  • ai@6.0.134dist/index.d.ts lines 4004-4007 (DefaultChatTransport) / 4042-4057 (DirectChatTransport) / 4078-4083 (TextStreamChatTransport)

Zapvol landing reference

  • packages/app/src/hooks/ — how a custom streaming hook is organized
  • packages/app/src/api/modules/task.ts — the front-end API client that skips useChat
Was this page helpful?