n4nAI

Multi-turn streaming chat in React with n4n.ai's API

Build a react multi-turn streaming chat api client with OpenAI-compatible endpoints, managing conversation state, token streaming, and errors in React.

n4n Team2 min read410 words

Audio narration

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

Wiring up a react multi-turn streaming chat api in React looks deceptively simple until you handle backpressure, abort controllers, and conversation history. This tutorial builds a minimal but production-shaped client against an OpenAI-compatible endpoint, using native fetch and the ReadableStream API—no SDK required.

Prerequisites

  • Node 18+ (global fetch and ReadableStream available)
  • A React 18 project (Vite or Next.js)
  • An API key for an OpenAI-compatible inference gateway (we’ll default to https://api.n4n.ai/v1/chat/completions, which covers 240+ models behind one endpoint)
  • Basic TypeScript and React hooks familiarity

Scaffold the project

npm create vite@latest chat -- --template react-ts
cd chat
npm install

Create a .env.local file for your key:

VITE_API_KEY=sk-your-key-here

Never expose this key in client-side code in production; we’ll note the proxy pattern later.

The streaming fetch layer

OpenAI-compatible chat completions with stream: true return Server-Sent Events. Each line is data: {json} and the stream terminates with data: [DONE]. The delta lives at choices[0].delta.content.

Create src/chatStream.ts:

export interface ChatMessage {
  role: 'system' | 'user' | 'assistant';
  content: string;
}

export async function* streamChat(
  messages: ChatMessage[],
  apiKey: string,
  signal: AbortSignal,
  baseUrl = 'https://api.n4n.ai/v1/chat/completions'
): AsyncGenerator<string> {
  const res = await fetch(baseUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages,
      stream: true,
    }),
    signal,
  });

  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  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]') return;
      const json = JSON.parse(data);
      const token = json.choices?.[0]?.delta?.content;
      if (token) yield token;
    }
  }
}

The generator yields raw string chunks. It does not accumulate them—that’s the UI’s job.

React state shape

The core of any react multi-turn streaming chat api is state management that appends tokens without re-rendering the whole list. Use useReducer to keep messages and a streaming flag.

import { useEffect, useReducer, useRef } from 'react';
import { streamChat, ChatMessage } from './chatStream';

interface State {
  messages: ChatMessage[];
  input: string;
  streaming: boolean;
}

type Action =
  | { type: 'set_input'; value: string }
  | { type: 'add_message'; message: ChatMessage }
  | { type: 'append_assistant'; token: string }
  | { type: 'set_streaming'; value: boolean };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'set_input':
      return { ...state, input: action.value };
    case 'add_message':
      return { ...state, messages: [...state.messages, action.message] };
    case 'append_assistant': {
      const messages = [...state.messages];
      const last = messages[messages.length - 1];
      if (last?.role === 'assistant') {
        messages[messages.length - 1] = { ...last, content: last.content + action.token };
      } else {
        messages.push({ role: 'assistant', content: action.token });
      }
      return { ...state, messages };
    }
    case 'set_streaming':
      return { ...state, streaming: action.value };
  }
}

Component wiring

export function Chat() {
  const [state, dispatch] = useReducer(reducer, {
    messages: [{ role: 'system', content: 'You are a concise helper.' }],
    input: '',
    streaming: false,
  });
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => () => abortRef.current?.abort(), []);

  async function send() {
    if (state.streaming || !state.input.trim()) return;
    const userMsg: ChatMessage = { role: 'user', content: state.input };
    const history = [...state.messages, userMsg];
    dispatch({ type: 'add_message', message: userMsg });
    dispatch({ type: 'set_input', value: '' });
    dispatch({ type: 'set_streaming', value: true });

    abortRef.current = new AbortController();
    try {
      for await (const token of streamChat(
        history,
        import.meta.env.VITE_API_KEY,
        abortRef.current.signal
      )) {
        dispatch({ type: 'append_assistant', token });
      }
    } catch (err) {
      if ((err as Error).name !== 'AbortError') console.error(err);
    } finally {
      dispatch({ type: 'set_streaming', value: false });
    }
  }

  return (
    <div>
      {state.messages.filter(m => m.role !== 'system').map((m, i) => (
        <div key={i}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      <input
        value={state.input}
        disabled={state.streaming}
        onChange={e => dispatch({ type: 'set_input', value: e.target.value })}
        onKeyDown={e => e.key === 'Enter' && send()}
      />
      <button onClick={send} disabled={state.streaming}>
        Send
      </button>
    </div>
  );
}

Checkpoint: first stream

Run npm run dev. Open the app, type “Hello”, and press Enter.

Expected raw SSE lines in the network tab:

data: {"choices":[{"delta":{"content":"Hi"},"index":0}]}
data: {"choices":[{"delta":{"content":" there"},"index":0}]}
data: [DONE]

The UI should render:

user: Hello
assistant: Hi there

No full-page flicker—only the assistant node updates as tokens land.

Multi-turn context handling

Each call sends the full messages array. That is correct for stateless HTTP APIs, but watch context windows. For a long react multi-turn streaming chat api session, slice the tail:

const MAX_HISTORY = 20;
const trimmed = history.slice(-MAX_HISTORY);

Keep the system prompt at index 0. If you rotate messages, re-insert it explicitly.

Abort and error discipline

The AbortController in useEffect cleanup cancels in-flight streams when the component unmounts. Wrap fetch errors and parse errors; the generator throws on non-2xx. Swallow AbortError silently, but surface others to a status region.

if ((err as Error).name === 'AbortError') return;
dispatch({ type: 'add_message', message: { role: 'assistant', content: '[stream failed]' } });

Production considerations

Shipping a react multi-turn streaming chat api to users means hiding your key. Put streamChat behind a Next.js route or a tiny proxy that injects the Authorization header server-side. The client then calls /api/chat instead of the gateway directly.

Because n4n.ai forwards provider cache-control hints and falls back automatically when a provider is degraded, the same client code stays resilient without writing retry branches. You only change the baseUrl and model string.

Token counting is per-call on the gateway side; meter usage from response headers if your gateway exposes them, not by guessing in the browser.

Keep the reducer pure, keep the generator dumb, and the stream will scale to thousands of concurrent sessions without React-specific surprises.

Tagsreactstreamingmulti-turnn4n-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 →