How does a chat hook like useChat manage message state while a response streams?
Advertisement
🧩 Scenario
Concept Walkthrough
Think of it as useReducer plus a fetch stream
If you have built a chat with useReducer, you already know the idea. There is one array of messages, and events change it: "user sent a message", "a chunk arrived", "stream finished", "request failed". useChat from @ai-sdk/react does this for you and adds the network part.
The message shape
Each UIMessage has an id, a role (user or assistant, among others) and parts. A part has a type. Text parts use 'text', and tool parts use 'tool-<toolName>'. This is why you render with message.parts.map(...) and switch on part.type.
The lifecycle of one reply
- You call
sendMessage({ text }). The user message is added tomessagesimmediately, so the UI updates without waiting for the server. statusbecomessubmitted: the request is sent and the hook is waiting for the stream to start.- The transport posts to the endpoint (
/api/chatby default, throughDefaultChatTransport). - The first chunk arrives and
statusbecomesstreaming. Chunks are applied to the assistant message, adding or updating its parts. - When the stream ends,
statusbecomesready. If the request fails, it becomeserroranderrorholds the error.
The helpers all edit the same array
stop()aborts the current stream. Whatever already arrived stays in the message.regenerate()regenerates the last assistant message, or a specific one.setMessages()replaces the array locally without calling the API, like a state setter. Use it to delete or edit messages.sendMessage()with no message resubmits the current messages.
Re-render control
Every chunk is a state change. The throttle option (milliseconds, React and Vue) limits how often reactive message updates are published. The docs say stream processing and callbacks still run immediately, and the latest messages are published before status becomes ready or error.
Key Code Explained
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
export function Conversation({ chatId, saved }: { chatId: string; saved: UIMessage[] }) {
const { messages, sendMessage, status, stop, regenerate, error } = useChat({
id: chatId,
messages: saved, // initial messages, for example loaded by a Server Component
throttle: 50, // publish UI updates at most every 50 ms
transport: new DefaultChatTransport({
api: '/api/chat',
// The server loads history by id, so only send the newest message.
prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => ({
headers: { 'X-Chat-ID': id },
body: { messages: messages.slice(-1), trigger, messageId },
}),
}),
});
const busy = status === 'submitted' || status === 'streaming';
return (
<section>
{messages.map((message) => (
<article key={message.id}>
{message.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>;
if (part.type.startsWith('tool-')) return <small key={i}>Using tool...</small>;
return null;
})}
</article>
))}
{status === 'submitted' && <p>Thinking...</p>}
{busy && <button onClick={() => stop()}>Stop</button>}
{status === 'error' && (
<button onClick={() => regenerate()}>Retry</button>
)}
<button disabled={busy} onClick={() => sendMessage({ text: 'Explain closures' })}>
Ask
</button>
</section>
);
}
Notice there is no useState for messages. The hook is the single source of truth. A custom wrapper hook should derive values from it (like busy) instead of copying the array, the same way you would not copy Redux state into local state.
Tradeoffs
| Option | Benefit | Cost |
|---|---|---|
| Default transport, all messages | Stateless server, zero setup | Request size grows with the conversation |
| Send only the latest message | Small requests, server owns history | Server must store and load messages by id |
| throttle set | Fewer re-renders on long answers | Text appears in slightly bigger steps |
| No throttle | Smoothest token-by-token display | More renders, can lag on long message lists |
What Interviewers Actually Check
- Whether you know the four statuses and what each one means for the UI
- Whether you render
parts, not a single string - Whether you treat the hook as the single source of truth
- Whether you know
stopkeeps partial output andsetMessagesdoes not call the API - Whether you know persistence is your job, on the server
Follow-Up Questions
- What happens to
messagesif the user callsstop()halfway through? - How would you let users edit an earlier message and continue from there?
- How do two components on the same page share one conversation?
- When would you use the
onFinishoronErrorcallbacks instead of readingstatus? - What does the
resumeoption solve, and what does the server need to support it?
Common Candidate Mistakes
- Using a single
isLoadingand showing "Thinking..." while text is already streaming - Reading a
contentstring and ignoring tool and data parts - Mirroring
messagesintouseState, which causes stale or duplicated messages - Expecting a page reload to keep the conversation without server storage
- Letting users send a new message mid-stream without disabling the button or stopping first
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you describe the
UIMessageshape? - Can you walk through submitted, streaming, ready and error?
- Can you explain
stop,regenerateandsetMessages? - Can you set
throttleand say what it changes? - Can you customise the request body with
DefaultChatTransport?
Summary
useChat is a reducer over an array of UIMessage objects, wired to a streaming request. sendMessage adds the user message right away, the status moves from submitted to streaming as chunks update the assistant message's parts, and it ends at ready or error. stop, regenerate and setMessages all act on the same array, throttle controls how often React re-renders, and a transport controls what is sent. Keep the hook as the single source of truth and store conversations on the server.
Why does a message have parts instead of a single content string?
One assistant reply can contain text, reasoning, tool calls with their status, files and custom data. A list of typed parts lets you render each one with the right component, the same way you would render a list of blocks in a CMS.
Does useChat save messages for me?
No. It holds state in memory on the client. You save messages on the server, usually when the stream ends, and pass saved messages back in through the messages option when the page loads.
How do two components show the same conversation?
The useChat reference describes passing one shared Chat instance through the chat option. Each component that calls useChat with that instance subscribes to the same state.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement