← Blog

How to Implement a Streaming Chat UI in React

2026-11-06 · 5 min read · SubToAPI Team

If you're searching for how to implement a streaming chat UI in React, you're probably past the "it works" stage and into "it feels slow" territory. A non-streaming chatbot waits for the full model response before showing anything, which means users stare at a spinner for 3–10 seconds. Streaming fixes that by rendering tokens as they arrive, so the first word appears in a few hundred milliseconds instead.

The core pattern is always the same, regardless of which LLM API you're calling: open a streaming connection (usually Server-Sent Events over HTTP), read chunks as they arrive, accumulate them into a growing string, and re-render that string into the DOM on every chunk. Everything else — stop/retry buttons, typing indicators, markdown rendering — is built on top of that loop.

What a streaming chat UI actually needs

Before writing code, be clear on the moving parts:

Most LLM providers, including Claude, stream responses as Server-Sent Events (SSE), where each event is a small JSON payload prefixed with data:. Your React code needs to parse that format, not just read raw bytes.

Reading a stream with fetch

You don't need a library for basic SSE consumption — fetch plus the Streams API handles it. Here's a backend-agnostic streaming reader:

async function streamChat(url, body, onToken, onDone) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  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 payload = line.slice(6);
      if (payload === "[DONE]") {
        onDone();
        return;
      }
      const json = JSON.parse(payload);
      if (json.delta?.text) onToken(json.delta.text);
    }
  }
}

This is the backbone of almost every streaming chat UI. Everything React-specific is just wiring this function to component state.

Wiring the stream into a React hook

Encapsulate the streaming logic in a custom hook so your chat component stays declarative:

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

function useStreamingChat(endpoint) {
  const [messages, setMessages] = useState([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const controllerRef = useRef(null);

  const sendMessage = useCallback(async (text) => {
    const userMsg = { role: "user", content: text };
    setMessages((prev) => [...prev, userMsg, { role: "assistant", content: "" }]);
    setIsStreaming(true);

    controllerRef.current = new AbortController();

    try {
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: text }),
        signal: controllerRef.current.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();

        for (const line of lines) {
          if (!line.startsWith("data: ")) continue;
          const payload = line.slice(6);
          if (payload === "[DONE]") continue;
          const json = JSON.parse(payload);
          const token = json.delta?.text || "";

          setMessages((prev) => {
            const next = [...prev];
            next[next.length - 1] = {
              ...next[next.length - 1],
              content: next[next.length - 1].content + token,
            };
            return next;
          });
        }
      }
    } catch (err) {
      if (err.name !== "AbortError") console.error(err);
    } finally {
      setIsStreaming(false);
    }
  }, [endpoint]);

  const stopStreaming = () => controllerRef.current?.abort();

  return { messages, sendMessage, stopStreaming, isStreaming };
}

The critical detail is updating the last message in the array by appending, not replacing — otherwise every chunk re-renders the entire conversation and loses the illusion of smooth typing.

Rendering without jank

A few things matter for perceived performance:

Handling errors and reconnects

Streams fail mid-response more often than regular requests: network drops, server timeouts, rate limits that trigger after the first few tokens. Wrap the reader loop in try/catch and give the UI a clear state to show partial output plus a retry option, rather than silently truncating the message. If you're building against a provider with usage limits, also surface those errors distinctly from network failures — users need to know "you hit a quota" versus "try again."

Skipping the backend plumbing

Everything above assumes you already have a streaming-capable API to call. If you're using Claude directly, you're responsible for managing API keys, handling SSE formatting from Anthropic's SDK, and exposing your own HTTPS endpoint for the frontend to hit — which is a reasonable chunk of backend work before you even get to the React part.

SubToAPI turns your existing Claude access into a standard HTTPS API with sub_live_... application keys, so the fetch call in the hook above points at https://api.subtoapi.app/v1/messages with stream: true in the body and an Authorization: Bearer $SUBTOAPI_KEY header — no separate SSE-proxy server to build. See /docs/streaming for the exact event format, /docs/messages for the request shape, and /docs/quickstart to get a key. Plans start at €9/month on /pricing, with a free trial at /signup.

questions

Do I need a WebSocket for a streaming chat UI in React? No. Server-Sent Events over a regular fetch response are simpler, work through standard HTTP infrastructure, and are what most LLM APIs use natively. WebSockets add complexity you don't need for one-directional token streaming.

Why does my UI freeze or lag while tokens stream in? Usually because every chunk triggers a re-render of the entire message list. Isolate the streaming message into its own memoized component so React only re-renders that one node per token.

How do I show a "stop generating" button during streaming? Create an AbortController, pass its signal into the fetch call, and call .abort() when the user clicks stop. 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 →