n4nAI

Auto-scrolling chat windows during LLM token streams in React

Implement react auto-scroll chat streaming with step-by-step code for smooth UX during LLM token streams, including sticky scroll and pause-on-scroll.

n4n Team2 min read490 words

Audio narration

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

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 &#123;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
&#125;

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
&lt;button onClick=&#123;stop&#125; disabled=&#123;!isStreaming&#125;>Stop&lt;/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”).

  1. During streaming, the view sticks to the newest token without flicker.
  2. Scroll up mid-stream. New tokens must NOT yank you to bottom.
  3. Scroll back to within 48px of bottom. Streaming resumes following.
  4. Resize the window. No spurious jumps occur.
  5. Open DevTools → Performance. Scroll events are passive; rAF callbacks fire at most once per frame.
  6. 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 setTimeout to scroll after render; it races with React’s commit phase. Use useLayoutEffect or 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.parse is 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.

Tagsreactchat-uistreamingux

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 →