Building a react custom hook llm streaming solution from scratch saves you from dragging heavy chat libraries into your bundle when all you need is a typed, abortable stream of tokens. This guide walks through a minimal useChatStream hook that talks to an OpenAI-compatible streaming endpoint and exposes messages, loading state, and an abort method. You will have a production-shaped pattern you can drop into any React 18 app.
Step 1: Stand up a streaming API route
Never call an LLM provider directly from the browser. You leak keys and lose the ability to enforce rate limits. Put a thin server route in front of the model. In Next.js App Router, an Edge route handles streaming with zero extra config.
// app/api/chat/route.ts
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
// An OpenAI-compatible gateway such as n4n.ai forwards cache-control
// and handles provider fallback; the request shape is identical.
const upstream = await fetch('https://api.n4n.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.N4N_API_KEY}`,
},
body: JSON.stringify({
model: 'openai/gpt-4o-mini',
messages,
stream: true,
}),
});
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
The route does one job: proxy the request, pass the raw upstream.body through, and set the correct text/event-stream header. If you run your own OpenAI key, swap the URL and auth header. The react custom hook llm streaming client code will not change.
Step 2: Define message types and hook signature
Typed contracts prevent the classic any rot that creeps into chat UIs. Declare a minimal role union and a ChatMessage shape.
type ChatRole = 'system' | 'user' | 'assistant';
interface ChatMessage {
role: ChatRole;
content: string;
}
interface UseChatStreamOptions {
apiUrl?: string;
initialMessages?: ChatMessage[];
}
interface UseChatStreamResult {
messages: ChatMessage[];
isStreaming: boolean;
sendMessage: (content: string) => void;
abort: () => void;
}
The hook returns the message list, a boolean for in-flight state, a send function, and an abort handle. That surface area is enough for 90% of chat UX.
Step 3: Implement the streaming reader
This is where the react custom hook llm streaming logic lives. Use fetch with signal, read the body with getReader(), and parse Server-Sent Events line by line. OpenAI-compatible streams emit data: {json}\n\n chunks terminated by data: [DONE].
import { useCallback, useRef, useState, useEffect } from 'react';
export function useChatStream(options: UseChatStreamOptions = {}): UseChatStreamResult {
const { apiUrl = '/api/chat', initialMessages = [] } = options;
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const messagesRef = useRef(messages);
useEffect(() => { messagesRef.current = messages; }, [messages]);
const sendMessage = useCallback(async (content: string) => {
const nextMessages = [...messagesRef.current, { role: 'user', content }];
setMessages([...nextMessages, { role: 'assistant', content: '' }]);
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: nextMessages }),
signal: controller.signal,
});
if (!res.body) throw new Error('No response 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 lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const token = json.choices?.[0]?.delta?.content ?? '';
if (token) {
setMessages((prev) => {
const copy = [...prev];
const last = copy[copy.length - 1];
copy[copy.length - 1] = {
role: 'assistant',
content: last.content + token,
};
return copy;
});
}
} catch {
// drop malformed keep-alive lines
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Stream failed', err);
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
}, [apiUrl]);
const abort = useCallback(() => {
abortRef.current?.abort();
}, []);
useEffect(() => {
return () => { abortRef.current?.abort(); };
}, []);
return { messages, isStreaming, sendMessage, abort };
}
Key details: we append an empty assistant message immediately so the UI shows a placeholder. Token deltas are concatenated via functional setMessages to avoid stale closure writes. The buffer handles chunks that split mid-line—a real occurrence on flaky connections.
Avoid stale messages with a ref
Depending on messages directly in sendMessage recreates the callback every render and can capture an outdated array during rapid sends. Mirroring into messagesRef (shown above) keeps the hook stable and correct under concurrency. Do not skip this in production.
Step 4: Clean up on unmount
React 18 StrictMode mounts, unmounts, and remounts. An in-flight stream must be aborted when the component disappears, or you leak readers and keep parsing after the UI is gone. The cleanup effect in the hook above already calls abortRef.current?.abort(). That triggers the AbortError path, which we swallow silently in the catch block.
Step 5: Wire it into a chat component
A consumer component should be boring. The react custom hook llm streaming hook hides all the SSE plumbing.
export function ChatBox() {
const { messages, isStreaming, sendMessage, abort } = useChatStream();
const [input, setInput] = useState('');
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div>
{messages.map((m, i) => (
<p key={i}><strong>{m.role}:</strong> {m.content}</p>
))}
</div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isStreaming}
/>
<button
onClick={() => { sendMessage(input); setInput(''); }}
disabled={isStreaming}
>
Send
</button>
{isStreaming && <button onClick={abort}>Stop</button>}
</div>
);
}
The Stop button calls abort(), which cancels the fetch and flips isStreaming to false in the finally block. No extra state machine required.
Step 6: Verify the stream end-to-end
Run the dev server and test the route independently before trusting the UI.
curl -N -X POST http://localhost:3000/api/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Say hi in 5 words"}]}'
You should see raw data: {...} lines streaming, ending with data: [DONE]. In the browser, open the Network tab, click Send, and confirm the request returns text/event-stream and the response payload fills incrementally. Type multiple messages; the assistant bubble should append tokens without flicker. Click Stop mid-stream; the UI should immediately return to idle and no further tokens appear.
If you proxy through a gateway, confirm it honors cache-control hints by checking response headers. The react custom hook llm streaming pattern does not care which backend serves the tokens as long as it speaks OpenAI-compatible SSE.
Where to take it next
Add optimistic UI for the user message, persist messages to IndexedDB, or layer tool-call parsing on top of the same delta loop. The hook is deliberately small—extend it, don’t replace it.