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/completionsreturns200withcontent-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 throwsAbortError; 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
sendagain 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
useRefto auto-scroll the chat container aspendinggrows.
The pattern scales to any SSE source. Swap the generator’s URL and message shape, and the React layer stays identical.