← Blog

Claude API Streaming Implementation in React.js

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

The direct answer

Streaming Claude API responses into a React UI means reading a text/event-stream response body chunk by chunk with the Fetch API's ReadableStream, parsing each Server-Sent Event (SSE) as it arrives, and appending the incoming text deltas to component state so the UI re-renders token-by-token instead of waiting for the full response. There's no special React library for this — you don't need EventSource (it can't send custom headers or POST bodies), and you don't need a streaming SDK wrapper. A single fetch call with a manual reader loop and a useState/useReducer hook is enough.

The rest of this guide walks through a working implementation: the fetch/reader setup, parsing the SSE payload format Claude's Messages API uses, a reusable useClaudeStream hook, and the common failure modes (stale closures, abort handling, duplicate renders) that trip people up in React specifically.

Why EventSource doesn't work here

EventSource only supports GET requests and can't attach an Authorization header or a JSON body, both of which the Messages API requires. That rules it out entirely. Instead you call fetch with stream: true in the request body, set Accept: text/event-stream, and read the response manually.

The core streaming loop

async function streamMessage(payload, onDelta, signal) {
  const res = 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({ ...payload, stream: true }),
    signal,
  });

  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 data = line.slice(6);
      if (data === "[DONE]") return;

      const event = JSON.parse(data);
      if (event.type === "content_block_delta") {
        onDelta(event.delta.text);
      }
    }
  }
}

The important detail: never assume one chunk equals one SSE event. TCP and HTTP buffering split and merge chunks unpredictably, so you must buffer partial lines and only parse complete data: lines. Skipping this step is the single most common bug in streaming implementations — text appears to randomly truncate or throw JSON parse errors under load.

A reusable React hook

Wrapping the loop in a hook keeps components clean and makes abort/cleanup correct:

import { useRef, useState, useCallback } from "react";

export function useClaudeStream() {
  const [text, setText] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef(null);

  const send = useCallback(async (payload) => {
    setText("");
    setIsStreaming(true);
    abortRef.current = new AbortController();

    try {
      await streamMessage(
        payload,
        (delta) => setText((prev) => prev + delta),
        abortRef.current.signal
      );
    } catch (err) {
      if (err.name !== "AbortError") console.error(err);
    } finally {
      setIsStreaming(false);
    }
  }, []);

  const stop = useCallback(() => {
    abortRef.current?.abort();
    setIsStreaming(false);
  }, []);

  return { text, isStreaming, send, stop };
}

Use it in a component like any other hook:

function ChatBox() {
  const { text, isStreaming, send, stop } = useClaudeStream();

  const handleSubmit = (prompt) => {
    send({
      model: "claude-sonnet-4-5",
      max_tokens: 1024,
      messages: [{ role: "user", content: prompt }],
    });
  };

  return (
    <div>
      <p>{text}</p>
      {isStreaming && <button onClick={stop}>Stop</button>}
    </div>
  );
}

Functional setState updates (setText(prev => prev + delta)) matter here — closures captured inside the async reader loop will reference stale state otherwise, which is a classic React streaming bug.

Handling tool use events while streaming

If the conversation involves tool calls, the event stream includes content_block_start, content_block_delta, and content_block_stop events for both text blocks and tool_use blocks, interleaved. Your parser needs to branch on event.type and track the current block's index and type rather than assuming every delta is plain text:

if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
  currentToolCall = { name: event.content_block.name, input: "" };
} else if (event.type === "content_block_delta" && event.delta.type === "input_json_delta") {
  currentToolCall.input += event.delta.partial_json;
}

Buffer the partial JSON and only JSON.parse it once content_block_stop fires for that block — partial JSON fragments won't parse. See the tool use docs for the full event shape if you're building an agent-style UI with streamed tool calls.

Avoiding re-render storms

Appending one character at a time with setState on every delta can cause dozens of re-renders per second on long responses. Two practical fixes:

Using SubToAPI instead of raw Anthropic credentials

If you already have Claude access through a subscription rather than a pay-as-you-go API key, SubToAPI turns that access into a standard HTTPS endpoint with sub_live_... application keys, so the fetch/reader pattern above works unchanged — you're just pointing at https://api.subtoapi.app/v1/messages instead of managing separate billing. It supports the same streaming response format, tool use, and usage metadata per request, which is useful if you want per-seat visibility into who's consuming tokens on a team. Check the streaming docs for event-by-event details, or the quickstart to get a key from the pricing page in a few minutes.

questions

Can I use EventSource for Claude API streaming in React? No. EventSource only supports GET requests without custom headers, and Claude's Messages API requires a POST with an Authorization header and JSON body. Use fetch with a manual ReadableStream reader instead.

Why does my streamed text sometimes get cut off or throw JSON errors? You're likely parsing raw fetch chunks as complete SSE events. Buffer incoming text and split on newlines, keeping incomplete trailing lines for the next chunk before calling JSON.parse.

How do I stop a stream early in React (e.g., a "stop generating" button)? Create an AbortController, pass its signal to fetch, and call abort() on the button click. Catch AbortError separately from other errors so you don't log it as a failure.

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 →