n4nAI

Building a streaming chat UI in React with ReadableStream

Build a react streaming chat ui readablestream tutorial: wire fetch and ReadableStream to render token-by-token chat responses in a React app with runnable code

n4n Team2 min read543 words

Audio narration

Coming soon — every post will get a voice note here.

A react streaming chat ui readablestream pattern lets you render LLM output token-by-token instead of blocking on a full response. This tutorial builds a minimal but production-shaped chat component that consumes a streaming endpoint via fetch and ReadableStream, with runnable code at each step.

Prerequisites

  • Node.js 18+ (fetch and ReadableStream are global)
  • React 18 and TypeScript
  • A streaming LLM endpoint that speaks OpenAI’s chat completions SSE format. You can run your own proxy or point at an OpenAI-compatible gateway.

Project setup

Scaffold a Vite React TS app:

npm create vite@latest stream-chat -- --template react-ts
cd stream-chat
npm install

Replace src/App.tsx with a skeleton that renders <Chat />. We’ll fill Chat later.

Run npm run dev. You should see the default Vite page at localhost:5173. That’s checkpoint one: the toolchain works.

The streaming fetch hook

The core of a react streaming chat ui readablestream is a generator that yields text deltas. OpenAI-style endpoints return text/event-stream with data: {json}\n\n frames. The [DONE] sentinel ends the stream.

We write a small parser that reads the response body as a ReadableStream<Uint8Array>, decodes chunks, splits on double newlines, and extracts choices[0].delta.content.

// src/streamChat.ts
import type { Message } from './types';

export async function* streamChat(
  messages: Message[],
  signal: AbortSignal,
  baseUrl = '/api/chat/completions'
) {
  const res = await fetch(baseUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages, stream: true }),
    signal,
  });
  if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
  if (!res.body) throw new Error('Response has no body');

  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 frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const line = frame.split('\n').find(l => l.startsWith('data:'));
      if (!line) continue;
      const payload = line.slice(5).trim();
      if (payload === '[DONE]') return;
      try {
        const json = JSON.parse(payload);
        yield json.choices?.[0]?.delta?.content ?? '';
      } catch {
        // ignore keep-alive comments or partial JSON
      }
    }
  }
}

This function is framework-agnostic. It works in any environment with fetch.

Why ReadableStream instead of EventSource

EventSource only does GET and forces you into a fixed SSE contract. LLM calls need POST with a body. ReadableStream gives you full control over the request and lets you abort mid-flight.

Building the chat state hook

We wrap the generator in a React hook that appends user messages, streams the assistant reply into a pending string, then commits it to messages.

// src/useChatStream.ts
import { useState, useRef, useCallback } from 'react';
import { streamChat } from './streamChat';
import type { Message } from './types';

export function useChatStream() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [pending, setPending] = useState('');
  const abortRef = useRef<AbortController | null>(null);

  const send = useCallback(async (content: string) => {
    const next: Message[] = [...messages, { role: 'user', content }];
    setMessages(next);
    setPending('');
    const ac = new AbortController();
    abortRef.current = ac;
    try {
      let assistant = '';
      for await (const token of streamChat(next, ac.signal)) {
        assistant += token;
        setPending(assistant);
      }
      setMessages([...next, { role: 'assistant', content: assistant }]);
      setPending('');
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setPending(`Error: ${(err as Error).message}`);
      }
    }
  }, [messages]);

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

  return { messages, pending, send, stop };
}

The UI components

A react streaming chat ui readablestream needs to show committed messages and the in-flight pending text without flicker. Keep the DOM simple:

// src/Chat.tsx
import { useState } from 'react';
import { useChatStream } from './useChatStream';

export function Chat() {
  const { messages, pending, send, stop } = useChatStream();
  const [input, setInput] = useState('');

  return (
    <div style={{ maxWidth: 600, margin: '2rem auto' }}>
      <div>
        {messages.map((m, i) => (
          <p key={i}><strong>{m.role}:</strong> {m.content}</p>
        ))}
        {pending && <p><strong>assistant:</strong> {pending}</p>}
      </div>
      <form onSubmit={e => {
        e.preventDefault();
        if (input.trim()) { send(input); setInput(''); }
      }}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Type a message"
          style={{ width: '80%' }}
        />
        <button type="submit">Send</button>
        <button type="button" onClick={stop}>Stop</button>
      </form>
    </div>
  );
}

Proxying the endpoint

Browser CORS and API key protection require a server-side proxy. In Vite, add a proxy target. If you point the proxy at an OpenAI-compatible endpoint like n4n.ai, the same SSE format applies and you gain access to 240+ models with automatic fallback when a provider is degraded.

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'https://api.n4n.ai/v1',
        changeOrigin: true,
        rewrite: p => p.replace(/^\/api/, ''),
        headers: {
          Authorization: `Bearer ${process.env.LLM_KEY}`,
        },
      },
    },
  },
});

Set LLM_KEY in a .env file. The client calls /api/chat/completions with no key exposure.

Checkpoint: expected behavior

Start the dev server, open the browser, and send “Explain streams in one sentence.”

Expected network trace:

  • A POST to /api/chat/completions returns 200 with content-type: text/event-stream.
  • The response panel shows multiple data: {...} frames arriving over time.
  • The UI shows assistant: text growing token by token, not all at once.

Sample rendered output mid-stream:

user: Explain streams in one sentence.
assistant: A stream lets a producer push data to a consumer incrementally

After completion, the pending text moves into the messages list and the input clears.

Handling edge cases

Real systems need more than happy path. Add these:

  • Empty delta: Some providers send heartbeat comments. The parser ignores non-JSON.
  • Abort: The Stop button fires AbortController.abort(). The generator throws AbortError; we swallow it.
  • Reconnect: If the connection drops, you can retry with the same messages array. For a react streaming chat ui readablestream, surface a “retry” action that calls send again with the last user message.

Going further

You now have a working react streaming chat ui readablestream with under 100 lines of core logic. To ship:

  • Move the proxy to a dedicated backend so you can log per-token usage metering.
  • Add markdown rendering for assistant messages.
  • Use useRef to auto-scroll the chat container as pending grows.

The pattern scales to any SSE source. Swap the generator’s URL and message shape, and the React layer stays identical.

Tagsreactstreamingchat-uireadablestreamtutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All react streaming chat ui patterns posts →