← Blog

Claude API Response Streaming in React: Full Guide

2026-10-27 · 5 min read · SubToAPI Team

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:

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.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →