Claude API Streaming UI: React Hooks Guide
When you're building a chat UI with Claude, users expect text to appear token by token, not as a single block after a multi-second wait. Getting that streaming behavior right in React means handling server-sent events, managing partial state updates without excessive re-renders, and cleaning up connections properly when a component unmounts or a user cancels a request. This article walks through a production-ready useClaudeStream hook you can drop into any React app.
The core pattern is always the same: open a fetch or EventSource connection to a streaming endpoint, read chunks as they arrive, parse the SSE data: lines, and accumulate text into component state. The tricky parts are cancellation (via AbortController), avoiding stale closures in async loops, and not re-rendering on every single token if your UI doesn't need that granularity.
Why Not Just Use EventSource
The native EventSource API is tempting for SSE, but it only supports GET requests and can't send custom headers like Authorization: Bearer .... Claude-compatible streaming APIs require a POST body (your messages array) and an auth header, so you need to use fetch with a ReadableStream reader instead. This is more code but gives you full control over the request.
The Hook
import { useState, useRef, useCallback } from "react";
function useClaudeStream(apiUrl, apiKey) {
const [text, setText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState(null);
const abortRef = useRef(null);
const send = useCallback(
async (messages) => {
setText("");
setError(null);
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "claude-3-5-sonnet",
max_tokens: 1024,
stream: true,
messages,
}),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`Stream request failed: ${res.status}`);
}
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.slice(6).trim();
if (payload === "[DONE]") continue;
try {
const event = JSON.parse(payload);
if (event.type === "content_block_delta") {
const delta = event.delta?.text ?? "";
setText((prev) => prev + delta);
}
} catch {
// ignore malformed partial JSON lines
}
}
}
} catch (err) {
if (err.name !== "AbortError") setError(err);
} finally {
setIsStreaming(false);
abortRef.current = null;
}
},
[apiUrl, apiKey]
);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
return { text, isStreaming, error, send, cancel };
}
Use it in a component like this:
function ChatBox() {
const { text, isStreaming, send, cancel } = useClaudeStream(
"https://api.subtoapi.app/v1/messages",
process.env.NEXT_PUBLIC_SUBTOAPI_KEY
);
return (
<div>
<button
onClick={() =>
send([{ role: "user", content: "Explain event loops in Node.js" }])
}
disabled={isStreaming}
>
Ask
</button>
{isStreaming && <button onClick={cancel}>Stop</button>}
<p>{text}</p>
</div>
);
}
This hook works against any Claude-compatible streaming endpoint, including SubToAPI, which turns your Claude access into a standard HTTPS API with sub_live_... application keys. If you're already using Claude through a subscription and want a stable REST endpoint for a React frontend without managing OAuth tokens or rate-limit quirks yourself, check the quickstart and streaming docs.
Handling Re-render Performance
Calling setText((prev) => prev + delta) on every token works fine for most chat UIs, but if you're streaming very long responses (thousands of tokens) and rendering markdown with syntax highlighting, re-rendering on every delta can get expensive. Two common fixes:
- Batch updates with
requestAnimationFrame: accumulate deltas in a ref, flush to state once per frame. - Debounce the render, not the fetch: keep reading the stream at full speed but only call
setTextevery 30–50ms using a simple timer.
const pending = useRef("");
const flushTimer = useRef(null);
function scheduleFlush(setText) {
if (flushTimer.current) return;
flushTimer.current = setTimeout(() => {
setText((prev) => prev + pending.current);
pending.current = "";
flushTimer.current = null;
}, 40);
}
This keeps typing feeling smooth without pegging the render thread on long completions.
Tool Use and Streaming Together
If your app uses tool calls, streamed responses will include content_block_start events with type: "tool_use" before the arguments stream in as JSON deltas. Your parser needs to track block index and type, accumulating the input_json_delta fragments separately from text deltas, then parsing the full JSON once the block closes. SubToAPI passes through the same event shape Anthropic's API uses, so any parsing logic you write against the official docs works unchanged — see the tools guide for the full event sequence.
Cleanup on Unmount
Always abort the controller when the component unmounts to avoid setting state on an unmounted component:
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
This is easy to forget and causes console warnings (or worse, memory leaks) in chat apps where users navigate away mid-stream.
FAQ
Can I use EventSource instead of fetch for Claude streaming? No, not directly — EventSource only supports GET requests and can't set an Authorization header, both of which you need for authenticated POST-based streaming. Use fetch with a ReadableStream reader instead.
How do I stop a stream when the user navigates away? Store the AbortController in a ref and call .abort() in your useEffect cleanup function. This closes the underlying connection immediately rather than letting it finish server-side.
Does this hook work with any Claude-compatible API, or just SubToAPI? It works with any endpoint that returns Anthropic-style SSE events (content_block_delta, message_stop, etc.). SubToAPI mirrors this event format exactly, so switching between a direct Anthropic setup and SubToAPI requires no changes to your parsing logic — just the URL and key.