How to Implement a Streaming Chat UI in React
If you're searching for how to implement a streaming chat UI in React, you're probably past the "it works" stage and into "it feels slow" territory. A non-streaming chatbot waits for the full model response before showing anything, which means users stare at a spinner for 3–10 seconds. Streaming fixes that by rendering tokens as they arrive, so the first word appears in a few hundred milliseconds instead.
The core pattern is always the same, regardless of which LLM API you're calling: open a streaming connection (usually Server-Sent Events over HTTP), read chunks as they arrive, accumulate them into a growing string, and re-render that string into the DOM on every chunk. Everything else — stop/retry buttons, typing indicators, markdown rendering — is built on top of that loop.
What a streaming chat UI actually needs
Before writing code, be clear on the moving parts:
- A streaming-capable backend endpoint that returns chunks as they're generated, not a single JSON blob at the end.
- A client-side reader that consumes the response body incrementally instead of calling
.json(). - State management that appends each chunk to the current message instead of replacing it.
- Cancellation support so users can stop a long generation mid-stream.
- Error and reconnect handling, because streams fail differently than regular requests.
Most LLM providers, including Claude, stream responses as Server-Sent Events (SSE), where each event is a small JSON payload prefixed with data:. Your React code needs to parse that format, not just read raw bytes.
Reading a stream with fetch
You don't need a library for basic SSE consumption — fetch plus the Streams API handles it. Here's a backend-agnostic streaming reader:
async function streamChat(url, body, onToken, onDone) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep incomplete line for next chunk
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") {
onDone();
return;
}
const json = JSON.parse(payload);
if (json.delta?.text) onToken(json.delta.text);
}
}
}
This is the backbone of almost every streaming chat UI. Everything React-specific is just wiring this function to component state.
Wiring the stream into a React hook
Encapsulate the streaming logic in a custom hook so your chat component stays declarative:
import { useState, useRef, useCallback } from "react";
function useStreamingChat(endpoint) {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const controllerRef = useRef(null);
const sendMessage = useCallback(async (text) => {
const userMsg = { role: "user", content: text };
setMessages((prev) => [...prev, userMsg, { role: "assistant", content: "" }]);
setIsStreaming(true);
controllerRef.current = new AbortController();
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controllerRef.current.signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") continue;
const json = JSON.parse(payload);
const token = json.delta?.text || "";
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = {
...next[next.length - 1],
content: next[next.length - 1].content + token,
};
return next;
});
}
}
} catch (err) {
if (err.name !== "AbortError") console.error(err);
} finally {
setIsStreaming(false);
}
}, [endpoint]);
const stopStreaming = () => controllerRef.current?.abort();
return { messages, sendMessage, stopStreaming, isStreaming };
}
The critical detail is updating the last message in the array by appending, not replacing — otherwise every chunk re-renders the entire conversation and loses the illusion of smooth typing.
Rendering without jank
A few things matter for perceived performance:
- Avoid re-rendering the whole message list on every token. Keep each message as its own memoized component (
React.memo) keyed by index or ID, so only the streaming message re-renders. - Defer markdown parsing until the stream finishes, or parse incrementally with a library that tolerates partial markdown (unclosed code blocks are the usual pain point).
- Auto-scroll conditionally — only scroll to bottom if the user hasn't manually scrolled up to read earlier messages.
- Show a stop button while
isStreamingis true, wired tostopStreaming().
Handling errors and reconnects
Streams fail mid-response more often than regular requests: network drops, server timeouts, rate limits that trigger after the first few tokens. Wrap the reader loop in try/catch and give the UI a clear state to show partial output plus a retry option, rather than silently truncating the message. If you're building against a provider with usage limits, also surface those errors distinctly from network failures — users need to know "you hit a quota" versus "try again."
Skipping the backend plumbing
Everything above assumes you already have a streaming-capable API to call. If you're using Claude directly, you're responsible for managing API keys, handling SSE formatting from Anthropic's SDK, and exposing your own HTTPS endpoint for the frontend to hit — which is a reasonable chunk of backend work before you even get to the React part.
SubToAPI turns your existing Claude access into a standard HTTPS API with sub_live_... application keys, so the fetch call in the hook above points at https://api.subtoapi.app/v1/messages with stream: true in the body and an Authorization: Bearer $SUBTOAPI_KEY header — no separate SSE-proxy server to build. See /docs/streaming for the exact event format, /docs/messages for the request shape, and /docs/quickstart to get a key. Plans start at €9/month on /pricing, with a free trial at /signup.
questions
Do I need a WebSocket for a streaming chat UI in React? No. Server-Sent Events over a regular fetch response are simpler, work through standard HTTP infrastructure, and are what most LLM APIs use natively. WebSockets add complexity you don't need for one-directional token streaming.
Why does my UI freeze or lag while tokens stream in? Usually because every chunk triggers a re-render of the entire message list. Isolate the streaming message into its own memoized component so React only re-renders that one node per token.
How do I show a "stop generating" button during streaming? Create an AbortController, pass its signal into the fetch call, and call .abort() when the user clicks stop. Catch AbortError separately from other errors so you don't log it as a failure.