Token-by-token LLM output breaks naive chat scroll logic. A robust react auto-scroll chat streaming implementation sticks to the bottom only when the user is already there, and yields control when they scroll up to read.
Step 1: Build a streaming fetch hook
Most LLM gateways emit Server-Sent Events (SSE) or newline-delimited JSON over a ReadableStream. Your first job is to consume that stream and append tokens to state without blocking the main thread.
import { useState, useCallback, useRef } from 'react';
interface Message { role: 'user' | 'assistant'; content: string; }
interface StreamChunk { token?: string; error?: string; }
export function useChatStream() {
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const send = useCallback(async (prompt: string) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setIsStreaming(true);
setMessages((m) => [
...m,
{ role: 'user', content: prompt },
{ role: 'assistant', content: '' },
]);
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: ctrl.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) {
if (!line.trim()) continue;
const chunk: StreamChunk = JSON.parse(line);
if (chunk.error) throw new Error(chunk.error);
if (chunk.token) {
setMessages((m) => {
const next = m.slice();
next[next.length - 1].content += chunk.token;
return next;
});
}
}
}
setIsStreaming(false);
}, []);
return { messages, send, isStreaming };
}
This hook is transport-agnostic. If your backend proxies an OpenAI-compatible endpoint, the chunk shape is identical. When you point it at a gateway like n4n.ai, the same parser works because the gateway forwards provider chunks unchanged and honors client routing directives.
Handling SSE instead of JSON lines
If the endpoint sends data: {json}\n\n, split on double newlines and strip the data: prefix before JSON.parse. The append logic stays the same.
Step 2: Render messages with a bottom anchor
You need a stable DOM node at the end of the list to measure scroll proximity. Do not rely on scrollHeight math alone; a ref to the last element is more reliable across font loading and images.
import { useRef } from 'react';
function ChatWindow({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
style={{ overflowY: 'auto', height: '70vh', padding: '1rem' }}
>
{messages.map((m, i) => (
<div
key={i}
className={m.role}
style={{ margin: '0.5rem 0', whiteSpace: 'pre-wrap' }}
>
{m.content}
</div>
))}
<div ref={bottomRef} />
</div>
);
}
The container has a fixed height and overflowY: auto. The empty bottomRef div sits after the final message. We will scroll this into view only when appropriate.
Step 3: Track user scroll intent
The core UX rule: auto-scroll only if the user is already near the bottom. If they scroll up to inspect earlier context, freeze the viewport.
const isAtBottom = useRef(true);
const THRESHOLD_PX = 48;
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottom.current = distance <= THRESHOLD_PX;
};
useEffect(() => {
const el = containerRef.current;
el?.addEventListener('scroll', handleScroll, { passive: true });
return () => el?.removeEventListener('scroll', handleScroll);
}, []);
Keep isAtBottom in a ref, not state. Writing scroll position to React state triggers a re-render of the entire message list on every wheel event—exactly what you want to avoid during high-frequency token streams.
Tuning the threshold
48px absorbs minor layout shifts from line wrapping. On touch devices, bump to 80px to account for momentum scroll. Test with a long code block stream to see where it feels natural.
Step 4: Auto-scroll on token append with rAF batching
A new token arrives multiple times per second. Calling scrollIntoView synchronously on every state update causes layout thrash. Batch with requestAnimationFrame.
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!isAtBottom.current) return;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
bottomRef.current?.scrollIntoView({ block: 'end' });
});
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [messages]);
This coalesces all token appends within a frame into one scroll operation. The browser paints once, and the user sees smooth downward motion.
Why not scrollTop = scrollHeight?
Direct assignment works but forces synchronous layout. scrollIntoView with block: 'end' is semantic and respects CSS scroll-behavior. If you prefer manual control, set el.scrollTop = el.scrollHeight inside the rAF callback instead.
Step 5: Handle resize and markdown streaming
Window resizes change clientHeight and can falsely flip isAtBottom. Re-evaluate on resize:
useEffect(() => {
const onResize = () => handleScroll();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
Streamed markdown introduces partial syntax—an unclosed ``` fence or incomplete table. Use white-space: pre-wrap and word-break: break-word so token width does not jump.
.assistant {
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
If you render markdown with react-markdown, parse on each token. It is cheap enough for moderate lengths; for huge outputs, debounce the parse and keep raw text in state.
Step 6: Wire abort and cleanup
Users expect a Stop button. Expose the abort controller:
const stop = () => abortRef.current?.abort();
// in component
<button onClick={stop} disabled={!isStreaming}>Stop</button>
On abort, the reader.read() loop throws AbortError. Catch it, set isStreaming false, and leave the partial assistant message intact. Do not force-scroll after abort; respect the user’s current position.
Step 7: Verify success
Run the dev server and open the chat in a browser. Send a prompt that triggers a long completion (e.g., “Write a 500-line Python script”).
- During streaming, the view sticks to the newest token without flicker.
- Scroll up mid-stream. New tokens must NOT yank you to bottom.
- Scroll back to within 48px of bottom. Streaming resumes following.
- Resize the window. No spurious jumps occur.
- Open DevTools → Performance. Scroll events are passive; rAF callbacks fire at most once per frame.
- Click Stop. Stream halts; partial text remains visible at its current scroll offset.
If all six hold, your react auto-scroll chat streaming is correct. The pattern decouples data flow from view control, so it scales to any number of models behind one endpoint.
Gotchas
- Never store scroll position in React state. Re-renders during token streams will drop frames.
- Do not use
setTimeoutto scroll after render; it races with React’s commit phase. UseuseLayoutEffector rAF. - If you virtualize the list (react-window, tanstack-virtual), pixel-distance logic breaks. Track the last visible item index and scroll to index instead.
- SSE connections may buffer. Add a heartbeat comment (
\n\n) on the server if proxies silence idle streams. - Per-token
JSON.parseis fine, but validate shape. A malformed line should not crash the stream; skip and log.
That is the full path from raw token stream to a chat UI that behaves like a native messaging app.