← Blog

Claude API Streaming UI: A React Example

2026-10-19 · 4 min read · SubToAPI Team

If you're searching for a working Claude API streaming UI in React, you're probably past the "why stream" stage and need the actual plumbing: how to read a server-sent event stream from the browser, append tokens to state without re-rendering the whole component tree, show a typing cursor, and cancel a request mid-stream. This article gives you that implementation, end to end.

The short answer: use fetch with a ReadableStream reader (not EventSource, which can't send custom headers or POST bodies), parse the data: lines manually, and update a single state variable with the accumulated text as chunks arrive. Below is a complete, minimal component you can drop into a React app.

Why EventSource doesn't work here

EventSource only supports GET requests and can't attach an Authorization header, which rules it out for any API key-based backend, including Claude's API directly or a proxy like SubToAPI. You need fetch with a streaming response body instead. This is standard for both Anthropic's native streaming endpoint and compatible wrappers.

The streaming request

Claude's streaming API (and compatible APIs) returns a sequence of SSE events. The event type that matters most for a chat UI is the incremental text delta — each chunk contains a small piece of the assistant's reply.

async function streamMessage(prompt, onDelta, signal) {
  const response = 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({
      model: "claude-sonnet-4",
      stream: true,
      messages: [{ role: "user", content: prompt }],
    }),
    signal,
  });

  const reader = response.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(5).trim();
      if (data === "[DONE]") return;

      try {
        const parsed = JSON.parse(data);
        if (parsed.type === "content_block_delta") {
          onDelta(parsed.delta?.text || "");
        }
      } catch {
        // ignore partial/malformed lines, they get completed next tick
      }
    }
  }
}

Note that API keys should never be exposed in client-side bundles in production. The example above uses NEXT_PUBLIC_ for simplicity; in a real app, this fetch should go through your own backend route that holds the key server-side and streams the response through to the browser unchanged.

The React component

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

export default function ClaudeChat() {
  const [input, setInput] = useState("");
  const [output, setOutput] = useState("");
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef(null);

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

    try {
      await streamMessage(
        input,
        (chunk) => setOutput((prev) => prev + chunk),
        abortRef.current.signal
      );
    } catch (err) {
      if (err.name !== "AbortError") {
        setOutput((prev) => prev + "\n\n[Error: stream failed]");
      }
    } finally {
      setIsStreaming(false);
    }
  }, [input]);

  const stop = () => abortRef.current?.abort();

  return (
    <div className="chat">
      <textarea
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={isStreaming}
        placeholder="Ask Claude something..."
      />
      <div className="actions">
        <button onClick={send} disabled={isStreaming || !input}>
          Send
        </button>
        {isStreaming && <button onClick={stop}>Stop</button>}
      </div>
      <div className="output">
        {output}
        {isStreaming && <span className="cursor">▊</span>}
      </div>
    </div>
  );
}

A few things worth noting:

Handling tool use and structured events while streaming

If your prompts trigger tool calls, the stream will include content_block_start events with a tool_use type and partial JSON deltas for the input arguments. A production UI typically buffers these separately from text deltas and renders a "Claude is using a tool…" indicator instead of raw JSON fragments. See /docs/tools for the event shapes if you're adding this.

Using SubToAPI as the backend

The code above targets https://api.subtoapi.app/v1/messages, which is SubToAPI's HTTPS endpoint for turning your existing Claude access into a normal API: you get an sub_live_... key from the dashboard, send standard messages requests with stream: true, and get back the same SSE event stream shown here — no changes to your React code regardless of which underlying model or plan you're on. This is useful if you want team members to share one Claude subscription through individual scoped API keys instead of each needing separate access. Full request/response shapes are in /docs/messages, and streaming-specific event details are in /docs/streaming. If you're starting from scratch, /docs/quickstart walks through generating a key after /signup.

Pricing is per-seat rather than per-token: Solo is €9/month for a single key, Team is €19/seat for shared dashboards and usage visibility, and Scale is €49/seat for larger teams — details on /pricing.

FAQs

Can I use EventSource instead of fetch for Claude streaming? No, not for authenticated POST requests. EventSource can't send custom headers or a JSON body, so any API key-based streaming endpoint requires fetch with a readable stream reader instead.

Why does my React UI lag or flicker during streaming? Usually it's updating state on every single token. Batch small chunks (e.g., every 2–3 deltas or every 30–50ms) before calling setState to cut down on re-renders without visibly slowing the typing effect.

How do I stop a Claude stream when the user navigates away or clicks cancel? Pass an AbortController signal into your fetch call and call .abort() on unmount or on a stop button click. The underlying connection closes immediately and no further chunks are processed.

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 →