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:
- The assistant bubble appears immediately with empty content.
- A tool-call block renders with the function name as soon as the first
tool_callsdelta withfunction.namearrives. - The argument
<pre>updates character-by-character as fragments stream. - After the stream closes, the JSON pretty-prints without console errors.
- Executing the tool appends a
toolmessage 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.