n4nAI

Rendering tool-call streams in React chat UIs

Learn how to build a react tool calling streaming ui that renders function-call events live from an OpenAI-compatible LLM stream, with runnable code.

n4n Team3 min read643 words

Audio narration

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

A react tool calling streaming ui needs to handle two asynchronous timelines at once: token fragments from the model and incremental updates to tool-call arguments. Most chat UI tutorials stop at text deltas, leaving engineers to improvise when the stream emits a tool_calls delta. This guide walks through a concrete implementation that renders function calls as they stream, using an OpenAI-compatible endpoint and a small React reducer.

Step 1: Define the message and tool-call data model

Before touching the network, lock down the shapes you will store in React state. A tool call in progress has no final arguments yet, so treat arguments as a raw string that you concatenate.

export interface ToolCall {
  id: string;
  name: string;
  arguments: string; // raw JSON string, may be incomplete
  result?: string;   // populated after execution
}

export interface ChatMessage {
  id: string;
  role: "user" | "assistant" | "tool";
  content: string;
  toolCalls?: ToolCall[];
}

export type ChatState = {
  messages: ChatMessage[];
};

Keep the assistant message and its toolCalls in the same object. This avoids juggling separate lists and makes rendering order trivial.

Step 2: Open the stream from an OpenAI-compatible endpoint

Use fetch with stream: true and parse the SSE body manually. If you route through a gateway such as n4n.ai, one OpenAI-compatible endpoint covers 240+ models and automatically fails over when a provider is degraded, so your client code stays identical across model swaps.

const res = await fetch("https://api.example.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${API_KEY}`,
  },
  body: JSON.stringify({
    model: "gpt-4o-mini",
    stream: true,
    messages: [{ role: "user", content: "What's the weather in Oslo?" }],
    tools: [
      {
        type: "function",
        function: {
          name: "get_weather",
          parameters: {
            type: "object",
            properties: { city: { type: "string" } },
            required: ["city"],
          },
        },
      },
    ],
  }),
});

if (!res.body) throw new Error("No response body");

Wrap this in a function that accepts the current messages and a callback for parsed chunks.

Step 3: Parse and accumulate deltas in a reducer

OpenAI streaming responses emit data: {choices:[{delta:{tool_calls:[{index, id, function:{name, arguments}}]}}]} lines. The arguments field arrives in fragments. You must append by index within the current assistant message.

type Action =
  | { type: "appendContent"; text: string }
  | { type: "upsertToolCall"; index: number; patch: Partial<ToolCall> };

function reducer(state: ChatState, action: Action): ChatState {
  const messages = [...state.messages];
  const last = messages[messages.length - 1];
  if (action.type === "appendContent") {
    last.content += action.text;
  } else if (action.type === "upsertToolCall") {
    last.toolCalls = last.toolCalls ?? [];
    const existing = last.toolCalls[action.index];
    if (!existing) {
      last.toolCalls[action.index] = {
        id: action.patch.id ?? "",
        name: action.patch.name ?? "",
        arguments: action.patch.arguments ?? "",
      };
    } else {
      if (action.patch.arguments)
        existing.arguments += action.patch.arguments;
      if (action.patch.name) existing.name = action.patch.name;
      if (action.patch.id) existing.id = action.patch.id;
    }
  }
  return { messages };
}

The reader loop splits on \n and ignores empty lines and the data: [DONE] sentinel.

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
  const { value, done } = 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.startsWith("data:")) continue;
    const data = line.slice(5).trim();
    if (data === "[DONE]") continue;
    const json = JSON.parse(data);
    const delta = json.choices?.[0]?.delta;
    if (delta?.content) dispatch({ type: "appendContent", text: delta.content });
    if (delta?.tool_calls) {
      for (const tc of delta.tool_calls) {
        dispatch({
          type: "upsertToolCall",
          index: tc.index,
          patch: {
            id: tc.id,
            name: tc.function?.name,
            arguments: tc.function?.arguments,
          },
        });
      }
    }
  }
}

This loop is the core of a robust react tool calling streaming ui: it never blocks the UI and keeps partial tool calls visible.

Step 4: Render streaming tool calls

Render the assistant message with a section that maps over toolCalls. While the stream is open, show a spinner and the raw argument string in a <pre>. Once the stream closes, pretty-print the parsed JSON.

function AssistantMessage({ msg }: { msg: ChatMessage }) {
  return (
    <div className="msg">
      <p>{msg.content}</p>
      {msg.toolCalls?.map((tc) => (
        <div key={tc.id} className="tool-call">
          <strong>🔧 {tc.name || "pending…"}</strong>
          <pre>{tc.arguments || "streaming arguments…"}</pre>
          {tc.result && <pre className="result">{tc.result}</pre>}
        </div>
      ))}
    </div>
  );
}

If you want live JSON syntax highlighting, feed tc.arguments through a tolerant parser that catches errors mid-stream. Don’t block render on JSON.parse; wrap in try/catch and fall back to the raw string.

Step 5: Execute tools and stream results back

When the assistant turn ends, inspect toolCalls. For each, call your backend, then append a tool message and optionally start a second stream with the combined context.

async function runToolsAndContinue(state: ChatState) {
  const lastAssistant = state.messages[state.messages.length - 1];
  if (!lastAssistant.toolCalls?.length) return;
  for (const tc of lastAssistant.toolCalls) {
    const args = JSON.parse(tc.arguments);
    const result = await fetch(`/api/${tc.name}`, {
      method: "POST",
      body: JSON.stringify(args),
    }).then((r) => r.text());
    tc.result = result;
    state.messages.push({
      id: crypto.randomUUID(),
      role: "tool",
      content: result,
      toolCalls: [{ id: tc.id, name: tc.name, arguments: tc.arguments, result }],
    });
  }
  // trigger another streaming completion with updated messages
}

Keep the tool execution on the server side; never expose API keys or internal functions to the client. The react tool calling streaming ui only renders the round trip.

Step 6: Verify the implementation

Run the app against a model that reliably emits a tool call. Use a simple get_weather function as shown. Success criteria:

  1. The assistant bubble appears immediately with empty content.
  2. A tool-call block renders with the function name as soon as the first tool_calls delta with function.name arrives.
  3. The argument <pre> updates character-by-character as fragments stream.
  4. After the stream closes, the JSON pretty-prints without console errors.
  5. Executing the tool appends a tool message and the next assistant response streams normally.

To test without a live provider, mock the fetch in Step 2 with a ReadableStream that pushes canned SSE lines:

const mockStream = new ReadableStream({
  start(controller) {
    const chunks = [
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":""}}]}}]}\n',
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\": \\"Os"}}]}}]}\n',
      'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"lo\\"}"}}]}}]}\n',
      "data: [DONE]\n",
    ];
    chunks.forEach((c) => controller.enqueue(new TextEncoder().encode(c)));
    controller.close();
  },
});

Point your hook at this stream and confirm the UI shows get_weather with {"city": "Oslo"} building up live. That proves your react tool calling streaming ui handles partial tool arguments correctly.

Handling edge cases

Real streams are messier. A provider may send arguments as a single string or split mid-escape. Your reducer’s string concatenation handles both. If a tool call is omitted because the model changed its mind, you will receive a new index with a different id; render it as a separate block.

Another gotcha: React 18 strict mode double-invokes effects. Guard your fetch with a cancellation flag so you don’t open two streams. Use AbortController and abort on cleanup.

useEffect(() => {
  const ctrl = new AbortController();
  startStream(ctrl.signal);
  return () => ctrl.abort();
}, []);

Finally, meter usage if you need cost tracking. The gateway you use may return usage in the final chunk; capture it in the [DONE] handling and display per-token counts. That closes the loop on a production-grade react tool calling streaming ui without adding client complexity.

Tagsreacttool-callingstreamingchat-ui

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 →