useChat, useObject, and the parts array
The Vercel AI SDK React hooks that subscribe to your streaming route and render each token as it arrives.
The last two lessons built the half of the system the user never sees: a route handler that reads the conversation, calls streamText or streamObject, and pushes tokens out over the wire one at a time. Nothing on the other end is catching them yet.
This lesson builds that other end. Something on the React side has to subscribe to the stream, accumulate the tokens, and re-render on every chunk so the user watches the answer type itself out. That something is a hook. By the end you’ll wire a streaming chat box and a structured-output form against the same /api/chat route, with input, live render, cancel button, and history all in place.
Two ideas anchor the work. First, your app deals in two shapes of message: a rich one it stores and renders, and a lossy one the model reads, with a single seam converting between them. Second, you render message.parts, never message.content. Hold those, and each of the three hooks is just the right tool for one job.
Two message types: what the app stores versus what the model reads
Section titled “Two message types: what the app stores versus what the model reads”You met UIMessage and ModelMessage at the route handler in this chapter’s first lesson, where you wrote the line that converts one into the other. Now you’re on the client side of that seam, where the distinction stops being a detail and becomes the thing the architecture is built around.
A UIMessage is the rich, app-owned shape: { id, role, parts } plus metadata. Its parts array holds prose, reasoning traces, file attachments, and the record of any tool the model called, the full texture of a turn. This is the conversation as the application understands it, the shape you store in the database and render in React.
A ModelMessage is the lossy shape: a role and some flattened text, the format the provider’s API expects. It’s all the model ever reads, and it drops your ids, metadata, and structure.
The app world and the model world speak different dialects, and exactly one place translates between them: the server, at the route handler, never the client.
App world
stored in the DB · rendered in React
{ id, role, parts } parts can hold:
Model world
all the model ever reads
role + text all flattened to:
Both converters live on the server. The client sends UIMessage[] up and receives stream parts down; it never touches ModelMessage.
The two arrows are the entire contract. Heading out, convertToModelMessages(messages) collapses your UIMessage[] into ModelMessage[] right before streamText. Coming back, result.toUIMessageStreamResponse() streams parts the client hook knows how to decode. The client calls neither converter, so the rich-versus-lossy negotiation stays a server concern.
Hence one rule, enforced twice more before the lesson ends: persist UIMessage[], never ModelMessage[]. Save the lossy shape and you discard the tool calls, reasoning, and metadata, so the next page load cannot faithfully rebuild the conversation. This is why the persistence write later saves the UI shape and nothing else.
The parts array is the render source of truth
Section titled “The parts array is the render source of truth”Now to that one rendering rule. A v5 message is { id, role, parts }. The field that matters is parts , an array where every element carries a type: 'text' for prose, 'reasoning' for a model’s chain-of-thought, 'file' for an attachment, 'tool-<name>' for a tool the model invoked, plus data parts you define yourself. A turn’s richness isn’t one string; it’s spread across that array.
So you render an assistant message by walking the array and switching on each part’s type:
{message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null,)}Here you only handle 'text', and everything else falls through to null. Why keep the array if you read one kind of part out of it? The next chapter answers that: tool calls render as 'tool-<name>' parts. The array is the slot rich content lands in once the model can do more than talk.
Most useChat tutorials online are written against v4, and many render the message the wrong way:
{messages.map((message) => ( <div key={message.id}>{message.content}</div>))}Drops every tool call, file, and reasoning part. In v5 there is no message.content string. The field is gone, so this renders empty for any message that isn’t pure text, and stops working the moment the model calls a tool.
{messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div>))}Renders every part, and stays correct as the model grows. Walking parts and switching on type is the only render that survives tool calls and attachments landing in the array later. Each mapped part needs a stable key. The part’s index within this message is fine; never reuse a bare array index across different messages.
The danger is that message.content doesn’t error in v5; it quietly returns nothing but text. It works perfectly in a demo where the model only says words, then the first time the model calls a tool, half the answer vanishes.
You follow a useChat tutorial. The chat works fine for plain answers, but the moment the assistant calls a tool, that part of the reply renders as nothing — the bubble is half-empty. The render code is {messages.map((m) => <div key={m.id}>{m.content}</div>)}. What’s wrong?
The render reads a field that only carries plain text and silently ignores everything richer; the tool result lives somewhere this code never looks.
The list is missing a key, so React refuses to render the tool part.
status isn’t being checked, so the component renders before the tool part has finished streaming.
The transport is misconfigured, so tool parts never reach the client at all.
message.content is the v4 shape — a flat string. In v5 a message’s content lives in its parts array, and .content only ever exposes the text, so any non-text part (a tool call) renders as nothing. The fix is message.parts.map(...) switching on part.type. The other options are red herrings: the render already has key={m.id}; status gates UX affordances, not whether a part renders; and a broken transport would stream nothing at all, yet plain text arrives fine.useChat: the conversation hook
Section titled “useChat: the conversation hook”You have the two facts every hook depends on: the two message types and the parts render. useChat is what you reach for when the surface is a conversation, a back-and-forth that accumulates history, and it’s the hook wired to the /api/chat route from the first lesson. We’ll build it up in stages.
Start with the call and what it returns:
const { messages, sendMessage, regenerate, status, error, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }),});The endpoint is wired through DefaultChatTransport, not a bare api string. useChat delegates sending to a ChatTransport , and DefaultChatTransport is the one that speaks HTTP POST plus a streaming response against your route, exactly what your handler returns. You won’t write a custom transport in this course. That’s why the endpoint is a property of the transport rather than an argument to the hook.
Notice what the hook does not return: no input, no handleInputChange, no handleSubmit. v4 managed the input text for you; v5 dropped that. You own the input with a plain useState, the same controlled form you’d write for any text field:
const [input, setInput] = useState('');That leaves status and stop, which drive the streaming UX. status is 'submitted' | 'streaming' | 'ready' | 'error'. The value you key off most is 'streaming': while it holds, you show the typing indicator, disable the input, and reveal a cancel button that calls stop(). This closes a loop from the previous chapter’s cost work: the abort propagates through the handler’s abortSignal down to the provider, so cancelling actually stops the model from generating, which stops burning tokens. Without a visible cancel control, a user who gives up on a long answer keeps paying for it, so every chat surface should offer a stop.
Two renames show up at the call site: v4’s append is now sendMessage, and v4’s reload is now regenerate. regenerate() re-runs the last assistant turn, your “try again” button when an answer comes back wrong.
Here is the whole component. It’s small, so step through it:
'use client';
export const Chat = () => { const { messages, sendMessage, status, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const [input, setInput] = useState('');
return ( <div> {messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(event) => setInput(event.target.value)} /> {status === 'streaming' && <button type="button" onClick={stop}>Stop</button>} </form> </div> );};'use client' because this component holds state and handles events. The hook is wired to your route through DefaultChatTransport, and everything below hangs off what it returns.
'use client';
export const Chat = () => { const { messages, sendMessage, status, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const [input, setInput] = useState('');
return ( <div> {messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(event) => setInput(event.target.value)} /> {status === 'streaming' && <button type="button" onClick={stop}>Stop</button>} </form> </div> );};The component owns the input, not the hook: a controlled input, the same useState form you’d write for any text field.
'use client';
export const Chat = () => { const { messages, sendMessage, status, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const [input, setInput] = useState('');
return ( <div> {messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(event) => setInput(event.target.value)} /> {status === 'streaming' && <button type="button" onClick={stop}>Stop</button>} </form> </div> );};The render walks messages, and for each message walks its parts, drawing the text parts. As tokens stream in, messages updates and this re-runs, which is the answer typing itself out.
'use client';
export const Chat = () => { const { messages, sendMessage, status, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const [input, setInput] = useState('');
return ( <div> {messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(event) => setInput(event.target.value)} /> {status === 'streaming' && <button type="button" onClick={stop}>Stop</button>} </form> </div> );};On submit, sendMessage({ text: input }) sends the text part up the transport, then setInput('') clears the box. { text } is the send shape for a text message.
'use client';
export const Chat = () => { const { messages, sendMessage, status, stop } = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }), }); const [input, setInput] = useState('');
return ( <div> {messages.map((message) => ( <div key={message.id}> {message.parts.map((part, index) => part.type === 'text' ? <span key={index}>{part.text}</span> : null, )} </div> ))}
<form onSubmit={(event) => { event.preventDefault(); sendMessage({ text: input }); setInput(''); }} > <input value={input} onChange={(event) => setInput(event.target.value)} /> {status === 'streaming' && <button type="button" onClick={stop}>Stop</button>} </form> </div> );};While the response streams, a Stop button appears, wired to stop(). Without it, a user who navigates away leaves the model generating, burning tokens for an answer nobody will read.
Persisting and resuming the conversation
Section titled “Persisting and resuming the conversation”A chat that forgets everything on refresh isn’t a product, so the conversation has to survive a reload. This is where the “two message types” rule pays off.
The decision is where the durable write happens, and the answer is server-side, in the handler’s onFinish, the same place the token counts and the audit event already land. The handler needs one small addition:
return result.toUIMessageStreamResponse({ originalMessages: messages, onFinish: ({ messages }) => saveChat({ chatId, messages }),});Two arguments do the work. originalMessages is the incoming history, the conversation as it stood when the request arrived. The messages handed to onFinish is the full UIMessage[] after the assistant’s turn finished streaming. You save that second one: the complete post-answer conversation, in the UI shape, following the “persist the UI shape, never the lossy shape” rule from earlier.
useChat also exposes its own onFinish on the client, and it’s tempting to save from there. Don’t. The client’s onFinish is for UI reactions: pinging analytics, scrolling to the bottom, popping a toast. It is not a reliable persistence trigger, and a user who closes the tab mid-stream never fires it at all. The durable write has to live somewhere that always runs to completion, and that’s the server’s onFinish.
That covers the write. The read happens in a Server Component: the chat page reads the conversation’s UIMessage[] straight from the database, scoped to the org, and passes them to the Client Component as a prop.
app/chat/page.tsx Server Component Renders on the server. No browser, no fetch.
getChat(chatId) Drizzle · org-scoped
Reads UIMessage[] straight
from the DB.
<Chat> Client Component · 'use client'
Mounts useChat({ messages }) — the history hydrates, the live stream takes over.
Static shell rendered on the server, live stream rendered on the client. The boundary is one prop hand-off, with no client fetch.
On the client, the component mounts the hook with that history wired into the messages option:
const { messages, sendMessage, status, stop } = useChat({ messages: initialMessages, transport: new DefaultChatTransport({ api: '/api/chat' }),});One naming trap is worth defusing. You’ll often see the value called initialMessages , but that’s just a variable name. The hook option it gets assigned to is messages — v4 had an option literally named initialMessages, and stale docs still say otherwise. Name your variable whatever reads well, and assign it to messages.
Note that the history is never fetched from the client. No useEffect, no extra round-trip: the Server Component already renders the page, so it reads the rows in-process and the prop carries them in.
useCompletion: single-shot text in, streaming text out
Section titled “useCompletion: single-shot text in, streaming text out”Not every AI surface is a conversation. Sometimes it’s one box, one button, one streaming answer, and no history: a “summarize this” widget, a draft generator, an autocomplete drawer. For those, useChat is the wrong tool, because it drags in a whole messages array and a parts render for a surface that has exactly one output and no memory. The right tool is useCompletion, the client twin of the single-shot text call:
const { completion, complete, isLoading, stop, error } = useCompletion({ api: '/api/complete',});completion is the streaming string, the answer growing token by token, ready to drop straight into the page. complete(prompt) fires the request. There’s no messages, no parts, no sendMessage, just a prompt in and a string out. Here is the minimal surface, simple enough that it needs no stepped walkthrough:
'use client';
export const SummaryBox = () => { const { completion, complete, isLoading } = useCompletion({ api: '/api/complete', }); const [input, setInput] = useState('');
return ( <div> <textarea value={input} onChange={(event) => setInput(event.target.value)} /> <button onClick={() => complete(input)} disabled={isLoading}> {isLoading ? 'Generating…' : 'Summarize'} </button> <p>{completion}</p> </div> );};The route behind it is the same shape you already know, with authedRoute and the quota guard, and one difference: it returns result.toTextStreamResponse(), the text protocol, not the parts protocol. The handler’s return helper is chosen by the hook on the other end. useChat wants parts, so the handler speaks toUIMessageStreamResponse; useCompletion wants a string, so it speaks toTextStreamResponse.
useObject: partial objects that fill in as they stream
Section titled “useObject: partial objects that fill in as they stream”The third hook is the client side of last lesson’s structured-output work. When the server primitive is streamObject, the client primitive is useObject, and it does something the others can’t: it renders a typed object that fills in field by field as the model parses it.
One import detail comes first, because it will trip you up otherwise. The hook still ships under an experimental name, experimental_useObject , so you import it under an alias:
import { experimental_useObject as useObject } from '@ai-sdk/react';The call mirrors the others, with one prop that ties the two halves of the system together:
const { object, submit, isLoading, stop, error } = useObject({ api: '/api/extract', schema: invoiceLineItemSchema,});That schema is the same Zod object you used on the server: the invoiceLineItemSchema from the last lesson, with its description, quantity, and unitAmount fields. One schema serves both ends of the wire, the contract living in one place that client and server both read from.
The interesting field is object. Its type is DeepPartial<RESULT> | undefined, and that deep partial is the whole point. As the stream parses, object builds up incrementally: first it’s undefined, then it has a description, then a quantity lands, then unitAmount. So you render it conditionally, each piece appearing as it arrives:
'use client';
export const LineItemExtractor = () => { const { object, submit, isLoading } = useObject({ api: '/api/extract', schema: invoiceLineItemSchema, }); const [input, setInput] = useState('');
return ( <div> <textarea value={input} onChange={(event) => setInput(event.target.value)} /> <button onClick={() => submit(input)} disabled={isLoading}> Extract line item </button> {object?.description != null && <p>{object.description}</p>} {object?.quantity != null && <span>Qty: {object.quantity}</span>} {object?.unitAmount != null && <span>{object.unitAmount}</span>} </div> );};submit(input) kicks it off, and isLoading and stop behave just like in the other hooks. The next figure shows what that conditional render buys you: the fields appearing one at a time as the model fills them in.
Line item
1 / 3 fieldsLine item
2 / 3 fieldsLine item
3 / 3 fieldsThat streaming render carries a design judgment the SDK won’t enforce for you: partial output has to read as progress, not a glitch. A chat box reading text as it streams feels natural, since you’re just reading along. But a structured form whose fields appear, change, and reorder as the model second-guesses itself looks broken. So stream into append-only or stable slots: let a field land and stay, and don’t let a half-parsed value flicker between guesses in front of the user. useObject hands you the partial; designing the surface so it reads as progress is on you.
Sort each workload into the hook that names it. Drag each item into the bucket it belongs to, then press Check.
Error states the user should actually see
Section titled “Error states the user should actually see”Models fail. The provider rate-limits you, a request times out, a user runs through their daily token budget. Every one of these hooks hands you an error and flips status to 'error', so the question isn’t whether you handle failure, it’s what you show when it happens.
Never render the raw error.message. Provider error strings leak vendor identifiers, model names, and occasionally stack fragments, which is an information disclosure on top of being poor UX. Render a fixed, friendly string and a retry control instead, holding the same baseline as the rest of the course: internal and vendor detail never reaches the client.
{status === 'error' && <p>{error.message}</p>}Leaks the provider’s raw failure to the user. That string can carry the vendor’s name, the model id, even a stack fragment: an information disclosure, and a confusing message besides. The user learns nothing actionable, and you’ve exposed your internals.
{status === 'error' && ( <div role="alert"> <p>The assistant couldn't finish that response.</p> <button onClick={() => regenerate()}>Try again</button> </div>)}A safe message and a way forward. A fixed, friendly string tells the user what happened without leaking anything, and regenerate() gives them the retry. The specifics of why it failed belong in your server logs, not the user’s screen.
The friendly text isn’t the model’s error string; it’s mapped from the status code of the handler’s RFC 9457 Problem Details response. A provider 429 or 5xx degrades to “try again in a moment,” and the exhausted daily quota from the cost lesson shows “you’ve used your daily limit, resets at midnight UTC.” Because the auth, quota, and rate-limit guards all sanitize in the handler, the client renders against a clean set of status codes instead of guessing at the provider’s raw failure: the status is the contract, the prose is yours.
The full lifecycle, end to end
Section titled “The full lifecycle, end to end”Here is the whole loop in one picture. Everything in this lesson sits somewhere on it: scrub through and watch a single message travel from page load to durable save, each step naming the seam and the chapter that owns it.
page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save page.tsx Server Component shell <Chat> · useChat Client Component input · useState plain controlled form parts render walks message.parts · status /api/chat route handler authedRoute guards identity · role · rate limit · quota convertToModelMessages → streamText toUIMessageStreamResponse + onFinish save External resources
Section titled “External resources”The hooks here are the stable core, but the AI SDK’s UI layer moves fast, so keep these references open while you build. Trust the current docs over any pinned snippet, your own or anyone else’s.
The canonical guide to building a chat surface with useChat — message rendering, the transport, status, and the streaming lifecycle.
The persistence section of this lesson in depth — saving UIMessage[] from onFinish server-side, loading history, and surviving a mid-stream disconnect.
The hook reference for streaming partial structured output into a typed object on the client.
Why message.content is gone and the input helpers left the hook — the exact v4-to-v5 changes that make the blog snippets you'll find online wrong.