Claude API Streaming UI: A React Example
If you're searching for a working Claude API streaming UI in React, you're probably past the "why stream" stage and need the actual plumbing: how to read a server-sent event stream from the browser, append tokens to state without re-rendering the whole component tree, show a typing cursor, and cancel a request mid-stream. This article gives you that implementation, end to end.
The short answer: use fetch with a ReadableStream reader (not EventSource, which can't send custom headers or POST bodies), parse the data: lines manually, and update a single state variable with the accumulated text as chunks arrive. Below is a complete, minimal component you can drop into a React app.
Why EventSource doesn't work here
EventSource only supports GET requests and can't attach an Authorization header, which rules it out for any API key-based backend, including Claude's API directly or a proxy like SubToAPI. You need fetch with a streaming response body instead. This is standard for both Anthropic's native streaming endpoint and compatible wrappers.
The streaming request
Claude's streaming API (and compatible APIs) returns a sequence of SSE events. The event type that matters most for a chat UI is the incremental text delta — each chunk contains a small piece of the assistant's reply.
async function streamMessage(prompt, onDelta, signal) {
const response = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.NEXT_PUBLIC_SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4",
stream: true,
messages: [{ role: "user", content: prompt }],
}),
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(); // keep incomplete line for next chunk
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
try {
const parsed = JSON.parse(data);
if (parsed.type === "content_block_delta") {
onDelta(parsed.delta?.text || "");
}
} catch {
// ignore partial/malformed lines, they get completed next tick
}
}
}
}
Note that API keys should never be exposed in client-side bundles in production. The example above uses NEXT_PUBLIC_ for simplicity; in a real app, this fetch should go through your own backend route that holds the key server-side and streams the response through to the browser unchanged.
The React component
import { useState, useRef, useCallback } from "react";
export default function ClaudeChat() {
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
const send = useCallback(async () => {
setOutput("");
setIsStreaming(true);
abortRef.current = new AbortController();
try {
await streamMessage(
input,
(chunk) => setOutput((prev) => prev + chunk),
abortRef.current.signal
);
} catch (err) {
if (err.name !== "AbortError") {
setOutput((prev) => prev + "\n\n[Error: stream failed]");
}
} finally {
setIsStreaming(false);
}
}, [input]);
const stop = () => abortRef.current?.abort();
return (
<div className="chat">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isStreaming}
placeholder="Ask Claude something..."
/>
<div className="actions">
<button onClick={send} disabled={isStreaming || !input}>
Send
</button>
{isStreaming && <button onClick={stop}>Stop</button>}
</div>
<div className="output">
{output}
{isStreaming && <span className="cursor">▊</span>}
</div>
</div>
);
}
A few things worth noting:
- One state variable for output. Appending to a single string and letting React re-render is fine for chat-length text. For very long documents, consider a ref-backed buffer with a throttled state flush every ~50ms to avoid excessive re-renders.
- AbortController for cancellation. Passing the
signalintofetchlets the "Stop" button actually cut the network request, not just hide the UI. - The blinking cursor is just CSS (
animation: blink 1s step-end infinite) applied to the▊character whileisStreamingis true — it's what makes the UI feel like it's actually typing rather than just getting unpredictable repaints.
Handling tool use and structured events while streaming
If your prompts trigger tool calls, the stream will include content_block_start events with a tool_use type and partial JSON deltas for the input arguments. A production UI typically buffers these separately from text deltas and renders a "Claude is using a tool…" indicator instead of raw JSON fragments. See /docs/tools for the event shapes if you're adding this.
Using SubToAPI as the backend
The code above targets https://api.subtoapi.app/v1/messages, which is SubToAPI's HTTPS endpoint for turning your existing Claude access into a normal API: you get an sub_live_... key from the dashboard, send standard messages requests with stream: true, and get back the same SSE event stream shown here — no changes to your React code regardless of which underlying model or plan you're on. This is useful if you want team members to share one Claude subscription through individual scoped API keys instead of each needing separate access. Full request/response shapes are in /docs/messages, and streaming-specific event details are in /docs/streaming. If you're starting from scratch, /docs/quickstart walks through generating a key after /signup.
Pricing is per-seat rather than per-token: Solo is €9/month for a single key, Team is €19/seat for shared dashboards and usage visibility, and Scale is €49/seat for larger teams — details on /pricing.
FAQs
Can I use EventSource instead of fetch for Claude streaming? No, not for authenticated POST requests. EventSource can't send custom headers or a JSON body, so any API key-based streaming endpoint requires fetch with a readable stream reader instead.
Why does my React UI lag or flicker during streaming? Usually it's updating state on every single token. Batch small chunks (e.g., every 2–3 deltas or every 30–50ms) before calling setState to cut down on re-renders without visibly slowing the typing effect.
How do I stop a Claude stream when the user navigates away or clicks cancel? Pass an AbortController signal into your fetch call and call .abort() on unmount or on a stop button click. The underlying connection closes immediately and no further chunks are processed.