Stream AI Responses With useChat: A Custom React UI That Handles Every State

5 min read

TL;DR Keep the input in your own state, call sendMessage, and render message.parts. Drive the card from status, but show the thinking state until the answer has text: in my test with reasoning tokens, status said streaming for 1.15 s before the first word. Pass onError in the route or users only see a generic error.

On this page
  1. Old useChat code doesn't compile anymore
  2. The route
  3. The answer card
  4. What the states looked like
  5. Stop, errors and scrolling
  6. If you want the answer to feel designed
  7. FAQ
  8. Why is input undefined in useChat?
  9. How do I show a loading state while the AI is thinking?
  10. How do I stop a streaming response with useChat?
  11. Why does useChat only say "An error occurred."?

To stream an AI reply into your own UI with the Vercel AI SDK, call useChat from @ai-sdk/react, keep the input in your own useState, send with sendMessage({ text }), and render each message's parts. Then drive the card from status. It goes submitted, then streaming, then ready or error. The catch is that streaming can start before the first word of the answer, so a "thinking" state that only checks for submitted leaves the user looking at nothing. Check that the answer still has no text as well.

I make animated AI UI for qodesign, including Prompt Bloom, an answer card that now streams a real reply through the AI SDK. For this post I built a small chat from scratch on Next.js 16.4.2 with React 19.3, Tailwind CSS 4.3, ai 7.0.147 and @ai-sdk/react 4.0.151. The model behind the route was the SDK's own mock model, not a real provider, and I'll say where that matters. Every file passed strict tsc and next build, then headless Chromium sent messages and recorded what the UI showed every 20 ms.

Old useChat code doesn't compile anymore

If your chat came from an older tutorial, or from an AI tool trained on one, it probably looks like this:

tsx
// components/OldTutorialChat.tsx (does not compile on @ai-sdk/react 4)
"use client";

import { useChat } from "@ai-sdk/react";

// What older tutorials (and a lot of AI-generated code) still write.
export function OldTutorialChat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();

  return (
    <form onSubmit={handleSubmit}>
      {messages.map((m) => (
        <p key={m.id}>{m.content}</p>
      ))}
      <input value={input} onChange={handleInputChange} />
    </form>
  );
}

Against the current packages, strict tsc gave four errors:

text
components/OldTutorialChat.tsx(7,21): error TS2339: Property 'input' does not exist on type 'UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>'.
components/OldTutorialChat.tsx(7,28): error TS2339: Property 'handleInputChange' does not exist on type 'UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>'.
components/OldTutorialChat.tsx(7,47): error TS2339: Property 'handleSubmit' does not exist on type 'UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>'.
components/OldTutorialChat.tsx(12,26): error TS2339: Property 'content' does not exist on type 'UIMessage<unknown, UIDataTypes, UITools>'.

useChat no longer manages the input, and a message no longer has a content string. You hold the input yourself, call sendMessage, and read text out of message.parts. A message can carry more than text in its parts (reasoning, tool calls, sources), which is why the component below filters for type === "text".

Prompt Bloom running live. An AI chat composer: the prompt lifts into a message and the answer card blooms out of the input.

The route

ts
// app/api/chat/route.ts
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { model } from "./model"; // swap in your provider, e.g. openai("gpt-5")

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model,
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse({
    // Without this the client only gets "An error occurred."
    onError: (error) => (error instanceof Error ? error.message : "Something went wrong"),
  });
}

The onError line matters more than it looks. In the SDK source, the default error handler for the UI stream returns the fixed string "An error occurred." so server details don't leak to the browser. With the handler above, my error test showed the actual message, "Model unavailable", in the card. Return something safe to show users here; don't pass raw provider errors through if they might contain anything private.

For the test, model was this mock. It waits 1.2 seconds before the first token and then sends a word every 40 ms, which is enough to see every state:

ts
// app/api/chat/model.ts
import { simulateReadableStream } from "ai";
import { MockLanguageModelV4 } from "ai/test";

// Test stand-in for a real model. It waits 1.2 s before the first token
// (the "thinking" gap), then streams one word every 40 ms.
// Ask for "long" to get a reply that overflows the card, "reason" to get a
// second of reasoning tokens before the answer, or "fail" for an error.
const REPLY =
  "Churn went from 2.1% to 3.4% last week. Most cancels came from the Starter plan, " +
  "right after the price email went out. A win-back offer for those accounts is the quickest fix.";

export const model = new MockLanguageModelV4({
  doStream: async ({ prompt }) => {
    const asked = JSON.stringify(prompt.at(-1) ?? "").toLowerCase();
    if (asked.includes("fail")) throw new Error("Model unavailable");
    const text = asked.includes("long") ? Array(8).fill(REPLY).join("\n\n") : REPLY;
    const words = text.split(/(?<= )/);
    const reasoning = asked.includes("reason")
      ? [
          { type: "reasoning-start" as const, id: "r1" },
          ...Array.from({ length: 25 }, () => ({ type: "reasoning-delta" as const, id: "r1", delta: "checking… " })),
          { type: "reasoning-end" as const, id: "r1" },
        ]
      : [];
    return {
      stream: simulateReadableStream({
        initialDelayInMs: 1200,
        chunkDelayInMs: 40,
        chunks: [
          ...reasoning,
          { type: "text-start", id: "t1" },
          ...words.map((delta) => ({ type: "text-delta" as const, id: "t1", delta })),
          { type: "text-end", id: "t1" },
          {
            type: "finish",
            finishReason: { unified: "stop", raw: undefined },
            usage: {
              inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
              outputTokens: { total: words.length, text: words.length, reasoning: undefined },
            },
          },
        ],
      }),
    };
  },
});

In a real app you replace the import with your provider's model and delete this file.

The answer card

tsx
// components/AnswerChat.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import type { UIMessage } from "ai";

function textOf(message: UIMessage) {
  return message.parts
    .filter((part) => part.type === "text")
    .map((part) => part.text)
    .join("");
}

export function AnswerChat() {
  const [input, setInput] = useState("");
  const { messages, sendMessage, status, stop, error, regenerate } = useChat({
    experimental_throttle: 50, // re-render at most every 50 ms while tokens arrive
  });

  const last = messages.at(-1);
  const answer = last?.role === "assistant" ? textOf(last) : "";
  const busy = status === "submitted" || status === "streaming";
  // "streaming" starts before the first word, so check for text too.
  const thinking = busy && answer.length === 0;

  // Follow new text unless the reader has scrolled up.
  const scroller = useRef<HTMLDivElement>(null);
  const pinned = useRef(true);
  useEffect(() => {
    const el = scroller.current;
    if (el && pinned.current) el.scrollTop = el.scrollHeight;
  }, [answer]);

  return (
    <div data-status={status} className="space-y-3">
      <div
        ref={scroller}
        onScroll={(e) => {
          const el = e.currentTarget;
          pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
        }}
        className="max-h-96 space-y-3 overflow-y-auto overscroll-contain rounded-2xl border bg-white p-4"
      >
        {messages.map((m) =>
          m.role === "user" ? (
            <p key={m.id} className="ml-auto w-fit max-w-[85%] rounded-xl bg-neutral-900 px-3 py-2 text-white">
              {textOf(m)}
            </p>
          ) : (
            <p key={m.id} className="whitespace-pre-wrap leading-relaxed" data-answer>
              {textOf(m)}
              {status === "streaming" && m.id === last?.id && answer && (
                <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-neutral-400 align-middle" />
              )}
            </p>
          ),
        )}

        {thinking && (
          <p role="status" className="text-sm text-neutral-500" data-thinking>
            Thinking…
          </p>
        )}

        {error && (
          <div role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
            {error.message}{" "}
            <button type="button" onClick={() => regenerate()} className="underline">
              Try again
            </button>
          </div>
        )}
      </div>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (!input.trim() || busy) return;
          sendMessage({ text: input });
          setInput("");
        }}
        className="flex gap-2"
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask about your metrics"
          aria-label="Message"
          className="min-w-0 flex-1 rounded-xl border px-3 py-2"
        />
        {busy ? (
          <button type="button" onClick={() => stop()} className="rounded-xl border px-4">
            Stop
          </button>
        ) : (
          <button type="submit" className="rounded-xl bg-neutral-900 px-4 text-white">
            Send
          </button>
        )}
      </form>
    </div>
  );
}

The page just renders <AnswerChat />.

What the states looked like

Times are from the moment the Send button was clicked.

  • 58 ms: status submitted, "Thinking…" shown.
  • 1,343 ms: status switched to streaming. The answer was still empty, so "Thinking…" stayed up.
  • 1,389 ms: the first word arrived, "Thinking…" went away and the text started filling in with the caret.
  • 2,745 ms: status ready, 175 characters, caret gone, Send button back.

With plain text the gap between streaming and the first word was only about 45 ms, one chunk. Then I asked the mock for a second of reasoning tokens before the answer, which is what a reasoning model does. Status turned streaming at 1,279 ms and the first word of the answer arrived at 2,434 ms. That's 1.15 seconds where status said streaming and there was no answer text. A card that hides its thinking state as soon as status leaves submitted would show an empty box for that whole stretch. The thinking line in the component covers it because it also checks answer.length === 0. If you want to show the reasoning itself, it arrives as parts with type === "reasoning" on the same message.

Real models will have their own timing, and I didn't test one. The order of the states comes from the SDK, so the shape should hold.

Stop, errors and scrolling

  • Stop: I sent a long prompt and clicked Stop after about 200 characters. Status went to ready right away, the partial answer stayed on screen (206 characters), and it hadn't grown 1.2 seconds later. The Send button came back.
  • Errors: asking the mock to fail put status at error and showed "Model unavailable" with a Try again button. Clicking it called regenerate(), which went back through submitted and, since the mock fails every time, ended at error again. The error test came after earlier answers, and those stayed on screen.
  • Scrolling: during a long reply the card stayed pinned to the bottom (0 px from the end). After I scrolled up with the mouse wheel, it stayed where I put it while the reply kept growing: the text below the visible area went from 272 px to 376 px in the next second. When I scrolled back to the bottom it followed again. overscroll-contain kept the page itself from moving (page scroll stayed at 0).
  • At 390 px wide nothing scrolled sideways, and there were no console errors in any run.

experimental_throttle: 50 limits re-renders to one every 50 ms while text arrives. With this mock sending a word every 40 ms it barely matters. It does matter when a fast model sends many small chunks and every one would otherwise re-render the whole message list.

If you want the answer to feel designed

The card above is deliberately plain. Prompt Bloom is the animated version: a "Thinking" pill that holds until the first token, a card that opens out of it, words that fade in at the pace they arrive, and sources and follow-up chips that come back when the stream ends. You pass it the text and status from useChat and its README has the tested route. Same caveat as this post: I tested it against the SDK's mock model, not a live provider. The AI chat UI demo page shows it on a landing page, and if you need the input side, this post covers an AI chat input with auto-grow, Enter to send and a stop button. Free pieces with full source are at /free.

FAQ

Why is input undefined in useChat?

Current versions of @ai-sdk/react removed input, handleInputChange and handleSubmit from useChat. Keep the input in your own useState and call sendMessage({ text: input }) on submit. Read the reply from message.parts instead of message.content.

How do I show a loading state while the AI is thinking?

Show it while status is submitted, and also while status is streaming but the last assistant message has no text yet. In my test with reasoning tokens, status said streaming for 1.15 seconds before any answer text arrived.

How do I stop a streaming response with useChat?

Call stop() from useChat. In my test the status went to ready immediately and the partial answer stayed on screen.

Why does useChat only say "An error occurred."?

That's the default message toUIMessageStreamResponse sends to the client so server errors don't leak. Pass an onError function that returns the message you want users to see.