Search IOCombats

Search challenges, guides, questions and articles

How does a chat hook like useChat manage message state while a response streams?

Intermediate10 min interview
Skills tested:
Describing the UIMessage shape and why it uses partsExplaining the status lifecycle: submitted, streaming, ready, errorKnowing what sendMessage, stop, regenerate and setMessages do to the message arrayUsing the throttle option to reduce re-renders during streamingCustomising the request with DefaultChatTransport

Advertisement

🧩 Scenario

You are reviewing a pull request that wraps useChat in a custom hook and copies messages into its own useState on every change. The interviewer asks you to explain how useChat already manages state during a stream, and what the custom hook should do instead.

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

  1. You call sendMessage({ text }). The user message is added to messages immediately, so the UI updates without waiting for the server.
  2. status becomes submitted: the request is sent and the hook is waiting for the stream to start.
  3. The transport posts to the endpoint (/api/chat by default, through DefaultChatTransport).
  4. The first chunk arrives and status becomes streaming. Chunks are applied to the assistant message, adding or updating its parts.
  5. When the stream ends, status becomes ready. If the request fails, it becomes error and error holds 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

OptionBenefitCost
Default transport, all messagesStateless server, zero setupRequest size grows with the conversation
Send only the latest messageSmall requests, server owns historyServer must store and load messages by id
throttle setFewer re-renders on long answersText appears in slightly bigger steps
No throttleSmoothest token-by-token displayMore 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 stop keeps partial output and setMessages does not call the API
  • Whether you know persistence is your job, on the server

Follow-Up Questions

  1. What happens to messages if the user calls stop() halfway through?
  2. How would you let users edit an earlier message and continue from there?
  3. How do two components on the same page share one conversation?
  4. When would you use the onFinish or onError callbacks instead of reading status?
  5. What does the resume option solve, and what does the server need to support it?

Common Candidate Mistakes

  • Using a single isLoading and showing "Thinking..." while text is already streaming
  • Reading a content string and ignoring tool and data parts
  • Mirroring messages into useState, 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 UIMessage shape?
  • Can you walk through submitted, streaming, ready and error?
  • Can you explain stop, regenerate and setMessages?
  • Can you set throttle and 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.

Frequently Asked Questions

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.

Namaste Dev
Sponsored

Cohort-style courses to go from tutorials to job-ready.

Explore Courses

Advertisement