ESC

Type to search the knowledge base.

Streaming Chat UI in React

A React chat UI is a list, a composer, in-flight state, and abort. Render tokens without jank, and keep the input usable.

intermediate6 min read
  • react
  • streaming
  • ui

You wired setMessages to every SSE chunk. The model is fine. The page is not. Each token clones the transcript, markdown re-parses from byte zero, and the composer hitch-steps while the main thread commits. A React chat UI is a list, a composer, in-flight state, and a way to abort.

Users judge you at first token paint, then at whether Stop stops. If the input locks up, they assume the tab died.

Key takeaways

  • Own four pieces: message list, composer, in-flight status, and an AbortController.
  • Isolate the streaming bubble. Do not re-render the archive on every token.
  • Batch paints (rAF or a short throttle). Token cadence is faster than frames.
  • Partial markdown is unsafe to parse as a finished document. Fence and emphasis can be open.
  • Stop must abort the fetch, not hide a spinner. Optimistic user bubbles stay until the server acks.

A React chat UI is four pieces

Piece Job
List Stable user and assistant rows. Keys are message ids, not array indexes.
Streaming row The only node that changes while tokens arrive.
Composer Controlled input. Send disabled in-flight. Stop enabled in-flight.
Transport fetch + ReadableStream to your origin, with a signal you can abort.

The AI SDK’s useChat is this model with a data-stream protocol on top. Use the hook when you want the protocol. Still know the pieces, because jank, Stop, and double-send are UI bugs, not model bugs. See Vercel AI SDK fundamentals for the hook, and streaming LLM responses for the wire format.

Optimistic send, then stream

Paint the user bubble before the network round-trip. Hold the assistant row in a ref while bytes arrive. Flush to React on animation frames so you do not schedule a render per token.

'use client';

import { useRef, useState } from 'react';

type ChatMsg = { id: string; role: 'user' | 'assistant'; text: string };

export function ChatComposer({ conversationId }: { conversationId: string }) {
  const [input, setInput] = useState('');
  const [history, setHistory] = useState<ChatMsg[]>([]);
  const [draft, setDraft] = useState('');
  const [busy, setBusy] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const draftRef = useRef('');
  const rafRef = useRef<number | null>(null);

  function flushDraft() {
    rafRef.current = null;
    setDraft(draftRef.current);
  }

  async function onSend() {
    const text = input.trim();
    if (!text || busy) return;

    const user: ChatMsg = { id: crypto.randomUUID(), role: 'user', text };
    setHistory((h) => [...h, user]);
    setInput('');
    setBusy(true);
    draftRef.current = '';
    setDraft('');

    const ac = new AbortController();
    abortRef.current = ac;

    try {
      const res = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ conversationId, messages: [...history, user] }),
        signal: ac.signal,
      });
      if (!res.ok || !res.body) throw new Error('chat failed');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        draftRef.current += decoder.decode(value, { stream: true });
        if (rafRef.current == null) {
          rafRef.current = requestAnimationFrame(flushDraft);
        }
      }
      const finalText = draftRef.current;
      setHistory((h) => [
        ...h,
        { id: crypto.randomUUID(), role: 'assistant', text: finalText },
      ]);
      draftRef.current = '';
      setDraft('');
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        const partial = draftRef.current;
        if (partial) {
          setHistory((h) => [
            ...h,
            { id: crypto.randomUUID(), role: 'assistant', text: partial },
          ]);
        }
        draftRef.current = '';
        setDraft('');
        return;
      }
      throw err;
    } finally {
      setBusy(false);
      abortRef.current = null;
    }
  }

  return (
    <>
      <ol>
        {history.map((m) => (
          <li key={m.id} data-role={m.role}>
            {m.text}
          </li>
        ))}
        {busy ? <li data-streaming>{draft}</li> : null}
      </ol>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          void onSend();
        }}
      >
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          // Keep typing responsive. Disable Send, not the field.
        />
        {busy ? (
          <button type="button" onClick={() => abortRef.current?.abort()}>
            Stop
          </button>
        ) : (
          <button type="submit">Send</button>
        )}
      </form>
    </>
  );
}

Memo the history rows if they become heavy (markdown, code). They receive frozen text. The streaming <li> is the only node that takes a changing string. That split is the performance trick.

Abort is the same primitive you use for search races. See AbortController. If you ignore AbortError and still append, Stop becomes “please ignore this” instead of cancel.

Why the list hitch-steps

The event loop will not paint until the current task yields. If you JSON.parse a growing prefix, run a full markdown pipeline, and setState inside one read callback, frames drop. Token events are not a license to do O(n) work on an n-token string plus an n-message list.

Three rules that hold up:

  1. Do not put the live string on the parent that maps history. History is frozen rows. The live string is a sibling.
  2. Do not parse markdown as a complete document while fences are open. An unclosed fence swallows the rest of the bubble. Render raw text until it closes, or use a streaming-aware tokenizer.
  3. Cap work per frame. requestAnimationFrame coalesces tokens. A short throttle on React state (useChat({ throttle })) is the same idea. Stream processing can stay eager. Paints should not.

This is different from Next.js HTML streaming and Suspense: RSC streams shells. Chat streams tokens into a client component.

Partial markdown and XSS-shaped text

Assistant text is untrusted the same way a CMS field is untrusted. Streaming does not make it safer. Do not dangerouslySetInnerHTML a marked-up partial. Prefer a markdown renderer that emits React nodes, then treat link targets and images as you would any user content. Prompt-injection in retrieved docs is a sibling problem. Here the bug is “we executed HTML the model streamed.”

Watch unclosed emphasis, fences, half-tables, and half-citations. Show a “generating” live region (aria-live="polite") on the streaming row so screen readers get updates without re-announcing the transcript.

Composer rules while bytes are in flight

  • Send is a no-op while busy. Double-submit forks the thread and bills twice. The server still needs an idempotency key.
  • Stop is visible only while busy. It calls abort(). Keep the partial or drop it. Decide once.
  • The textarea stays mounted and focused. Unmounting it on send is what “locks the input” feels like. Disable submit, not the field, unless you are blocking a second turn on purpose.
  • Scroll the list, not the window, and only if the user is pinned to the bottom.

Conversation ids, trimming, and what you persist are conversation state, not this page. Do not dump 80 turns into localStorage from the composer.

Footguns

  • key={index} on bubbles. A Stop-and-resend shifts indexes. State in children (collapsed code, copied flag) jumps rows.
  • Storing the ReadableStream reader in React state. Readers are not serializable. Refs.
  • Parsing SSE lines with split('\\n') on each chunk and dropping a torn line. Keep a leftover buffer.
  • Calling setState from a useEffect that depends on draft. You already have the read loop. An effect that “syncs” the draft will double-render and can loop.
  • Leaving the stream running on unmount. Abort in the effect cleanup or you write to an unmounted tree and keep the provider generating.

Interview answer (out loud)

“A React chat UI is a message list, a composer, in-flight status, and an AbortController. I paint an optimistic user bubble, keep history as frozen rows, and put the live token string in a child so the archive does not re-render. I batch paints with rAF because token events outrun frames, and I do not run a full markdown parse on an open fence. Stop aborts the fetch. The browser talks to my origin, not to the model provider.”

Further reading (primary sources)

Related guides