---
name: build-streaming-chat-ui
description: Builds the chat window for an AI assistant in React - message list, input box, words appearing as they stream in, a stop button, auto-scroll, loading and error states, and safe rendering of the bot's markdown. Use when the user wants a ChatGPT-like chat interface, a chat widget for their site, or streaming AI responses shown on screen.
license: CC-BY-4.0
compatibility: React 18+; pairs with build-ai-chatbot-api-route
metadata:
  category: frontend
  tags: "chatbot,streaming,react"
  author: johnny-bravo
  version: "1.0"
  updated: "2026-10-31"
  status: published
---

# Build a streaming chat UI

Show the reply word by word as it arrives, so the chat feels fast even when the model is slow.

## When to use
- You have a chat API route and need the screen part.
- Replies appear all at once after a long wait, and you want streaming.
- You want a small chat widget in the corner of your site.

## Steps
1. **Hold the state.** Keep `messages` (role + text), `input`, `status` ("idle", "streaming" or "error") and an `AbortController` for stopping.
2. **Send.** On submit, add the user's message and an empty assistant message. Then POST the history to `/api/chat` with the controller's `signal`.
3. **Read the stream.** Use `res.body.getReader()` with a `TextDecoder`, and append each chunk to the last assistant message.
4. **Stop button.** While streaming, show "Stop", which calls `controller.abort()`. Keep whatever text already arrived.
5. **Auto-scroll.** Scroll to the newest message, but only when the user is already near the bottom.
6. **States.** Disable Send while the input is empty or a reply is streaming. Show a typing indicator before the first chunk. On error, show "Something went wrong - try again" with a Retry button.
7. **Render safely.** Show bot text as plain text, or use a markdown renderer that does not allow raw HTML. Never use `dangerouslySetInnerHTML` on model output.
8. **Accessibility.** Give the message list `aria-live="polite"`, label the input, and let Enter send while Shift+Enter adds a new line.

## Example (core of the send function)
```tsx
async function send(text: string) {
  const next = [...messages, { role: "user", content: text }, { role: "assistant", content: "" }];
  setMessages(next); setStatus("streaming");
  const ctrl = new AbortController(); ctrlRef.current = ctrl;
  try {
    const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ messages: next.slice(0, -1) }), signal: ctrl.signal });
    if (!res.ok || !res.body) throw new Error(String(res.status));
    const reader = res.body.getReader(); const dec = new TextDecoder();
    for (;;) {
      const { done, value } = await reader.read(); if (done) break;
      const chunk = dec.decode(value, { stream: true });
      setMessages((m) => { const c = [...m]; c[c.length - 1] = { ...c[c.length - 1], content: c[c.length - 1].content + chunk }; return c; });
    }
    setStatus("idle");
  } catch (e) { setStatus((e as Error).name === "AbortError" ? "idle" : "error"); }
}
```

## Gotchas
- Model output can contain HTML or script tags. Rendering it as raw HTML is an XSS hole.
- Do not store chats with personal or private data in `localStorage` on shared computers without telling users.
- React strict mode runs effects twice in development, so do not auto-send from an effect.

## Works with any stack
The same pattern works in Vue, Svelte or plain JavaScript. If you use the Vercel AI SDK, its `useChat` hook handles steps 1-4 instead, but check that its stream format matches your route.

## Done when
- Words appear as they stream, and Stop halts them right away.
- A failed request shows an error with Retry, and the page does not freeze.
- A reply containing `<script>` shows as text and does not run.

## Example prompt
"Build a ChatGPT-style chat window for my app that streams the answer and has a stop button."
