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.
- 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:
- Do not put the live string on the parent that maps history. History is frozen rows. The live string is a sibling.
- 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.
- Cap work per frame.
requestAnimationFramecoalesces 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 callsabort(). 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
ReadableStreamreader 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
setStatefrom auseEffectthat depends ondraft. 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 on this site
- Streaming LLM responses
- Conversation state in chat UIs
- Vercel AI SDK fundamentals
- AbortController
- The event loop
Related guides
- Conversation State in Chat UIsChat conversation state is messages, in-flight status, and a conversation id. Persist on the server, trim for the model, cache in the UI.
- ReAct vs Plan-and-ExecuteReAct interleaves thought and tools. Plan-and-execute writes the plan first. Pick based on tool cost, reversibility, and how often the world changes mid-run.
- Streaming LLM ResponsesStreaming is tokens over the wire so the UI can paint before the full answer exists. Backpressure, abort, and what to do with a partial JSON object.
- The Think, Act, Observe LoopThe agent loop is: reason, pick a tool, run it, feed the result back, repeat until stop. Timeouts, max steps, and what "done" means.
- Time to First Token and LatencyUsers feel TTFT. Total time is TTFT plus generation. Streaming, smaller models, and prompt cache hit rate are the knobs that actually move the UI.