Claude API Response Streaming in React: Full Guide
Streaming Claude API responses in React means rendering tokens as they arrive instead of waiting for the full completion, so your UI updates incrementally like ChatGPT or Claude's own chat interface. The core pattern is: open a streaming connection to the API, read chunks as they arrive via the Fetch API's ReadableStream, parse the server-sent events (SSE), and push each text delta into React state so it re-renders progressively.
This is different from a normal fetch().then(res => res.json()) call, which blocks until the entire response is ready. For long completions — code generation, long explanations, multi-step reasoning — users perceive streaming as dramatically faster even though total generation time is the same. Below is a complete, working implementation you can adapt directly.
Why streaming matters for chat UIs
Without streaming, a user asking Claude to write a 2,000-token response stares at a spinner for several seconds. With streaming, the first token appears almost immediately and text fills in continuously. This is almost mandatory for any production chat interface, coding assistant, or support bot built on top of Claude.
The tradeoff is complexity: you now need to handle partial JSON, chunked text, abort signals, and reconnect logic on the client. React makes this manageable if you isolate the streaming logic into a single hook.
Setting up the stream request
Anthropic's Messages API (and compatible gateways like SubToAPI) support "stream": true, which returns a text/event-stream response instead of a single JSON blob. Each event is a line prefixed with data: containing a JSON payload describing a delta.
A minimal request looks like this:
const response = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: "Explain event loops in Node.js" }],
}),
});
The response body is a ReadableStream. You never call .json() on it — you read it manually with a TextDecoder.
Building a reusable streaming hook
Wrap the parsing logic in a custom hook so components stay clean:
import { useState, useRef, useCallback } from "react";
export function useClaudeStream() {
const [text, setText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
const send = useCallback(async (prompt) => {
setText("");
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
const res = await fetch("https://api.subtoapi.app/v1/messages", {
method: "POST",
signal: controller.signal,
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUBTOAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
const reader = res.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.replace("data:", "").trim();
if (payload === "[DONE]") continue;
try {
const event = JSON.parse(payload);
if (event.type === "content_block_delta") {
setText((prev) => prev + event.delta.text);
}
} catch {
// ignore partial/malformed chunks, buffer handles reassembly
}
}
}
setIsStreaming(false);
}, []);
const cancel = useCallback(() => {
abortRef.current?.abort();
setIsStreaming(false);
}, []);
return { text, isStreaming, send, cancel };
}
Key details that make this robust:
- Buffering incomplete lines. SSE chunks don't align with line boundaries, so the last split line is held back and prepended to the next chunk.
- AbortController for cancellation. Letting users stop generation mid-stream is expected UX; without
signal, you can't cancel afetchin progress. - Functional state updates.
setText(prev => prev + delta)avoids stale closures when updates arrive faster than React re-renders.
Using the hook in a component
function ChatBox() {
const { text, isStreaming, send, cancel } = useClaudeStream();
const [prompt, setPrompt] = useState("");
return (
<div>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button onClick={() => send(prompt)} disabled={isStreaming}>
Send
</button>
{isStreaming && <button onClick={cancel}>Stop</button>}
<p>{text}</p>
</div>
);
}
React batches state updates automatically in modern versions, so frequent setText calls during a fast stream won't cause excessive re-renders in practice. If you notice jank on very long responses, throttle updates with a small buffer that flushes every ~50ms instead of on every delta.
Avoiding the API key exposure trap
The example above assumes a server-side proxy or a key scoped for client use — never ship a raw Anthropic secret key into browser JavaScript. The common production pattern is: your backend holds the real credential, and the frontend talks to your own /api/chat route, which forwards the stream. This is also where a service like SubToAPI fits well: it issues application-scoped sub_live_... keys per client or project, so you can rotate and revoke access without touching your main Anthropic account, and the streaming format matches what's shown above. See /docs/streaming for the exact event schema and /docs/quickstart for setup.
Handling errors mid-stream
Streams can fail after they've already started (rate limits, network drops, upstream timeouts). Wrap the reader loop in a try/catch and surface a partial-result state in your UI rather than discarding what already rendered — users generally prefer a cut-off answer with a visible error over a blank screen.
try {
// reader loop
} catch (err) {
if (err.name !== "AbortError") {
setText((prev) => prev + "\n\n[Stream interrupted]");
}
}
Questions
Does the Claude API support streaming out of the box? Yes — set "stream": true in the Messages API request and read the response as text/event-stream instead of parsing it as a single JSON object.
Can I stream Claude responses from a Next.js API route? Yes, use a Route Handler that proxies the upstream stream with ReadableStream passthrough, keeping your API key server-side while the browser reads the forwarded stream.
Why does my React UI lag or batch text oddly during streaming? Usually either React isn't re-rendering per-chunk due to closure issues (fix with functional setState) or you're parsing SSE lines without buffering incomplete chunks across reads.