← Blog

Claude API Streaming Responses with React Hooks

2026-09-25 · 5 min read · SubToAPI Team

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:

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.

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 →