Claude API Streaming Responses with React Hooks
The short answer
To stream Claude API responses in React, you need a custom hook that opens a streaming connection, reads the response body as a series of chunks, and updates component state on every chunk so the UI re-renders progressively. The core building blocks are the Fetch API's ReadableStream, AbortController for cancellation, and a useState/useReducer pair to hold the accumulating text.
This pattern is the same whether you're calling Anthropic's Messages API directly or a compatible gateway like SubToAPI. Below is a complete, reusable useClaudeStream hook you can drop into a Next.js or Vite React project, plus the common pitfalls that break streaming in practice (stale closures, missing cleanup, double-fetch in Strict Mode).
Why streaming needs its own hook
A normal fetch().then(res => res.json()) call waits for the entire response before you get anything back. For long Claude completions, that can mean several seconds of a blank screen. Streaming fixes this by sending the response as Server-Sent Events (SSE), so your UI can render tokens as they arrive — the same effect you see in Claude.ai or ChatGPT.
React doesn't have a built-in streaming primitive, so you're responsible for:
- Opening the stream and reading it chunk by chunk
- Parsing SSE frames (
data: {...}\n\n) into usable text deltas - Updating state without triggering a re-render per byte
- Cancelling the request when the component unmounts or the user clicks "stop"
- Handling errors mid-stream (a connection can fail after it's already sent partial output)
Building the hook
Here's a minimal, dependency-free implementation:
import { useCallback, useRef, useState } from "react";
export function useClaudeStream({ endpoint, apiKey }) {
const [text, setText] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState(null);
const controllerRef = useRef(null);
const send = useCallback(async (messages, model = "claude-sonnet-4") => {
setText("");
setError(null);
setIsStreaming(true);
const controller = new AbortController();
controllerRef.current = controller;
try {
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, messages, stream: true }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error(`Stream 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\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.replace(/^data: /, "").trim();
if (!trimmed || trimmed === "[DONE]") continue;
try {
const parsed = JSON.parse(trimmed);
const delta = parsed?.delta?.text ?? "";
if (delta) setText((prev) => prev + delta);
} catch {
// ignore incomplete JSON chunks
}
}
}
} catch (err) {
if (err.name !== "AbortError") setError(err.message);
} finally {
setIsStreaming(false);
}
}, [endpoint, apiKey]);
const stop = useCallback(() => {
controllerRef.current?.abort();
}, []);
return { text, isStreaming, error, send, stop };
}
Using it in a component:
function Chat() {
const { text, isStreaming, error, send, stop } = useClaudeStream({
endpoint: "https://api.subtoapi.app/v1/messages",
apiKey: process.env.NEXT_PUBLIC_SUBTOAPI_KEY,
});
return (
<div>
<button
onClick={() =>
send([{ role: "user", content: "Explain streaming in one paragraph." }])
}
disabled={isStreaming}
>
Ask
</button>
{isStreaming && <button onClick={stop}>Stop</button>}
{error && <p>Error: {error}</p>}
<p>{text}</p>
</div>
);
}
Never ship your real API key in client-side code for production — proxy the request through your own backend, or use a service that scopes keys per application. SubToAPI's sub_live_ keys are meant to sit behind your server, with the frontend calling your own /api/chat route instead.
Common pitfalls
Stale closures in the reader loop. If you reference component state directly inside the while loop instead of using the functional updater (setText(prev => prev + delta)), you'll silently drop tokens on fast streams. Always use the functional form when appending.
React Strict Mode double-invokes effects. If you trigger send() inside a useEffect on mount, Strict Mode in development will call it twice, opening two streams. Guard with a ref flag or trigger streaming from a user action instead of on mount.
Not aborting on unmount. If a user navigates away mid-stream, the fetch keeps running and can throw a "setState on unmounted component" warning. Call controllerRef.current?.abort() in a useEffect cleanup function tied to the component's lifecycle.
Buffering the wrong boundary. SSE frames are separated by a blank line (\n\n), not a single newline. Splitting on \n alone will cut JSON payloads in half and cause parse errors.
Assuming every provider's delta shape is identical. Anthropic's Messages API sends content_block_delta events with a delta.text field, but the exact event names differ from OpenAI-style APIs. If you're switching between providers or using a gateway, check the actual event payload rather than assuming.
When to reach for a gateway instead of raw fetch
The hook above works against any endpoint that returns SSE, including SubToAPI's /v1/messages endpoint, which mirrors the Anthropic Messages API shape so this code doesn't need provider-specific branching. If you're already managing multiple app environments, team members, or need per-key usage metadata without building that dashboard yourself, it's worth looking at how request logging and rate limits are handled — see the streaming docs and Messages API reference for the exact request/response format, or the quickstart to get a key in a few minutes.
For teams shipping multiple internal tools on top of Claude, keeping API keys centralized with per-seat billing (see pricing) tends to be simpler than distributing raw provider credentials across repos.
Questions
Does the Claude API support streaming out of the box? Yes. Setting "stream": true in the Messages API request returns a Server-Sent Events stream of incremental content deltas instead of a single JSON blob.
Can I use EventSource instead of fetch for this? EventSource only supports GET requests and can't send custom headers or a JSON body, so it doesn't work for the Messages API's POST-based streaming. Stick with fetch and manually parse the ReadableStream.
How do I cancel a stream if the user navigates away? Store an AbortController in a ref, call .abort() in your useEffect cleanup function, and check for err.name === "AbortError" in your catch block so it doesn't get treated as a real error.