n4nAI

React streaming chat UI with Zustand and n4n.ai's API

Build a react zustand llm streaming chat UI with OpenAI-compatible APIs. Step-by-step tutorial covering store design, SSE parsing, and component wiring.

n4n Team3 min read675 words

Audio narration

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

A solid react zustand llm streaming chat interface needs more than a fetch call—it needs a state model that handles partial tokens, race conditions, and abortable requests without thrashing the render tree. This tutorial builds a minimal but production-shaped chat client against an OpenAI-compatible endpoint, using Zustand for state and native browser streams for token delivery.

Prerequisites

  • Node 18+ and npm
  • Familiarity with React function components and hooks
  • TypeScript basics
  • An API key for an OpenAI-compatible LLM gateway. We’ll point at n4n.ai’s OpenAI-compatible endpoint, which covers 240+ models and fails over automatically when a provider is degraded.
  • A Vite React-TS scaffold (commands below)

No prior Zustand experience required, but you should know what a store is.

Scaffold the project

npm create vite@latest chat-ui -- --template react-ts
cd chat-ui
npm install zustand
touch src/store.ts src/api.ts

Set your key in .env.local:

VITE_API_KEY=sk-your-key-here

Vite exposes vars prefixed with VITE_ on import.meta.env.

Model the chat state with Zustand

The core problem in a react zustand llm streaming chat is that tokens arrive at high frequency. If you store the entire message array in a single React state and replace it on every token, you’ll re-render the whole list. Zustand lets components subscribe to slices, and its set is cheap.

Define a strict shape:

// src/store.ts
import { create } from 'zustand'

export type Role = 'user' | 'assistant' | 'system'

export interface Message {
  id: string
  role: Role
  content: string
  streaming?: boolean
}

interface ChatState {
  messages: Message[]
  isLoading: boolean
  addMessage: (m: Message) => void
  appendToMessage: (id: string, chunk: string) => void
  setLoading: (v: boolean) => void
}

export const useChatStore = create<ChatState>((set) => ({
  messages: [],
  isLoading: false,
  addMessage: (m) => set((s) => ({ messages: [...s.messages, m] })),
  appendToMessage: (id, chunk) =>
    set((s) => ({
      messages: s.messages.map((m) =>
        m.id === id ? { ...m, content: m.content + chunk } : m
      ),
    })),
  setLoading: (v) => set({ isLoading: v }),
}))

Key detail: appendToMessage maps over the array but returns new object references only for the targeted message. Components rendering other messages won’t see a changed reference if you use selector equality, keeping renders local.

Implement the streaming client

OpenAI-compatible endpoints return Server-Sent Events when stream: true. The body is a newline-delimited data: {json} stream terminating with data: [DONE]. We parse incrementally because fetch gives us a raw ReadableStream, not a line reader.

// src/api.ts
const API_URL = 'https://api.n4n.ai/v1/chat/completions'
const API_KEY = import.meta.env.VITE_API_KEY

export async function streamChat(
  messages: { role: string; content: string }[],
  onToken: (t: string) => void,
  signal: AbortSignal
) {
  const res = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages,
      stream: true,
    }),
    signal,
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  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]') return
      const json = JSON.parse(data)
      const token = json.choices?.[0]?.delta?.content
      if (token) onToken(token as string)
    }
  }
}

This function is transport-only. It does not know about Zustand; it just calls onToken. That separation makes it testable with a fake callback.

Build the React components

We’ll keep one App component for brevity, but extract the message list into a memoized subcomponent so token appends don’t re-render the input box.

// src/App.tsx
import { useRef, useState, memo } from 'react'
import { useChatStore } from './store'
import { streamChat } from './api'

const MessageList = memo(function MessageList({
  messages,
  isLoading,
}: {
  messages: ReturnType<typeof useChatStore.getState>['messages']
  isLoading: boolean
}) {
  return (
    <div className="messages">
      {messages.map((m) => (
        <div key={m.id} className={`msg ${m.role}`}>
          <strong>{m.role}:</strong> {m.content}
          {m.streaming && isLoading && m.role === 'assistant' ? '▌' : ''}
        </div>
      ))}
    </div>
  )
})

export default function App() {
  const messages = useChatStore((s) => s.messages)
  const isLoading = useChatStore((s) => s.isLoading)
  const addMessage = useChatStore((s) => s.addMessage)
  const appendToMessage = useChatStore((s) => s.appendToMessage)
  const setLoading = useChatStore((s) => s.setLoading)

  const [input, setInput] = useState('')
  const abortRef = useRef<AbortController | null>(null)

  async function send() {
    if (!input.trim() || isLoading) return
    const userMsg: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: input,
    }
    addMessage(userMsg)
    setInput('')
    const assistantId = crypto.randomUUID()
    addMessage({ id: assistantId, role: 'assistant', content: '', streaming: true })
    setLoading(true)
    const controller = new AbortController()
    abortRef.current = controller
    try {
      await streamChat(
        [...messages, userMsg].map(({ role, content }) => ({ role, content })),
        (token) => appendToMessage(assistantId, token),
        controller.signal
      )
    } catch (e) {
      if ((e as Error).name !== 'AbortError') {
        appendToMessage(assistantId, '\n[stream error]')
      }
    } finally {
      setLoading(false)
      abortRef.current = null
    }
  }

  function stop() {
    abortRef.current?.abort()
  }

  return (
    <div className="chat">
      <MessageList messages={messages} isLoading={isLoading} />
      <div className="composer">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && send()}
          placeholder="Type a message"
        />
        {isLoading ? (
          <button onClick={stop}>Stop</button>
        ) : (
          <button onClick={send}>Send</button>
        )}
      </div>
    </div>
  )
}

Import the Message type at top: import { useChatStore, Message } from './store'.

Checkpoint: stream your first message

Run npm run dev, open the local URL. Type “Explain Zustand in one sentence” and hit Enter.

Expected behavior:

  1. Your message appears immediately under user:.
  2. An empty assistant: line shows with a cursor.
  3. Text appears token-by-token, roughly 20–60 ms apart depending on model and network.
  4. Cursor disappears when the stream ends.

If you see the full response pop at once, confirm stream: true is in the body and that you’re not awaiting res.json().

Abortable requests and UX

The AbortController wired above lets the user halt generation. One subtlety: after abort, the finally block sets isLoading false, but the assistant message retains streaming: true in the store. For correctness, flip that flag:

// inside finally, after setLoading(false):
useChatStore.setState((s) => ({
  messages: s.messages.map((m) =>
    m.id === assistantId ? { ...m, streaming: false } : m
  ),
}))

Without this, the cursor would linger if you ignore isLoading in the selector.

Production considerations for react zustand llm streaming chat

Key safety. Never ship VITE_API_KEY to the client in real apps. Proxy the stream through a backend that injects the key, or use short-lived signed tokens. The pattern above is for local prototyping.

Backpressure. If tokens arrive faster than React commits, batch appends with requestAnimationFrame. Zustand’s set is synchronous; a mid-frame flood still causes many renders. A simple throttle:

let acc = ''
let raf = 0
function flush() {
  if (acc) { appendToMessage(assistantId, acc); acc = '' }
  raf = 0
}
onToken: (t) => {
  acc += t
  if (!raf) raf = requestAnimationFrame(flush)
}

Provider routing. Gateways that are OpenAI-compatible often accept extra headers or body fields. The gateway we used honors client routing directives and forwards provider cache-control hints, so if your model supports prompt caching, include the provider’s cache_control field in the message payload; it passes through untouched.

Error boundaries. Wrap MessageList in an error boundary. A malformed SSE line shouldn’t blank the whole chat.

Why this shape holds up

The react zustand llm streaming chat architecture separates three concerns: transport (api.ts), state (store.ts), and view (App.tsx). That separation means you can swap the streaming source for WebSocket or a different gateway without touching component logic. Zustand’s selector subscriptions keep token bursts from re-rendering your input or header. And because the store is just a hook, you can drive multiple chat windows from one store with per-thread slices if you later extend the shape.

If you build from this scaffold, you have a working client in under 150 lines that degrades gracefully, supports stop, and parses the standard stream format correctly.

Tagsreactzustandstreamingn4n-ai

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 →