Wiring up react abortcontroller llm streaming cancel logic is the difference between a chat UI that feels responsive and one that leaks tokens and connections when users hit stop. Most React apps fire a fetch and forget the handle; when the model is mid-stream, you need to tear down the reader and the underlying socket. This guide walks through a complete pattern for canceling in-flight LLM streams from a React component.
Step 1: Own the AbortController in a dedicated hook
Don’t scatter new AbortController() across event handlers. Centralize streaming state in a custom hook so every new send cancels the previous run and exposes a single cancel() function. Store the controller in a ref, not state—state updates trigger re-renders and can capture stale closures.
import { useRef, useState, useCallback } from 'react';
export function useLLMStream() {
const abortRef = useRef<AbortController | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [text, setText] = useState('');
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const send = useCallback(async (prompt: string) => {
cancel(); // tear down any prior stream first
const controller = new AbortController();
abortRef.current = controller;
setIsStreaming(true);
setText('');
try {
const res = await fetch('https://api.example.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
stream: true,
}),
signal: controller.signal,
});
if (!res.body) throw new Error('No response body');
// reader logic added in Step 2
} catch (err) {
if ((err as Error).name !== 'AbortError') throw err;
} finally {
setIsStreaming(false);
abortRef.current = null;
}
}, [cancel]);
return { send, cancel, isStreaming, text, setText };
}
The critical line is cancel() at the top of send. That guarantees a rapid second message doesn’t leave two streams fighting over the same state. Without it, a user clicking Send twice will interleave tokens.
Step 2: Parse Server-Sent Events without blocking the abort
OpenAI-compatible endpoints return newline-delimited data: frames. You must read the body stream incrementally and parse lines, not wait for the full response. The AbortController signal propagates to the fetch; when aborted, reader.read() rejects with AbortError.
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 frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const line = frame.trim();
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content ?? '';
setText((prev) => prev + delta);
} catch {
// ignore malformed keep-alive comments
}
}
}
If you route through a gateway such as n4n.ai, the same signal cancels the stream on its single OpenAI-compatible endpoint covering 240+ models, and per-token metering stops at disconnect. The AbortError is expected; swallow it specifically so it doesn’t bubble as an unhandled rejection. Note that some providers send periodic : ping comments—your parser must ignore lines not starting with data:.
Handling reader cleanup explicitly
After an abort, call reader.cancel() in a finally block to release the underlying stream if the loop didn’t exit via done:
} finally {
try { await reader.cancel(); } catch {}
setIsStreaming(false);
}
Step 3: Bind cancel to UI and keyboard shortcuts
A stop button is table stakes. Power users expect Escape to abort. Wire both to the hook’s cancel and reflect streaming state in the DOM.
import { useEffect, useState } from 'react';
import { useLLMStream } from './useLLMStream';
export function ChatBox() {
const { send, cancel, isStreaming, text } = useLLMStream();
const [input, setInput] = useState('');
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isStreaming) cancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isStreaming, cancel]);
return (
<div>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask something…"
/>
<button onClick={() => send(input)} disabled={isStreaming}>
Send
</button>
<button onClick={cancel} disabled={!isStreaming}>
Stop
</button>
<pre>{text}</pre>
</div>
);
}
Disable Send while streaming to prevent overlapping calls. The Escape listener depends on isStreaming so it doesn’t fire when idle. This completes the react abortcontroller llm streaming cancel wiring for the view layer.
Step 4: Clean up on unmount and avoid state updates after abort
React warns if you setText on an unmounted component. Track mounted state and abort on unmount. This also protects against StrictMode double-mount in development, where effects run twice.
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
abortRef.current?.abort();
};
}, []);
// inside the reader loop, guard state updates:
if (mounted.current) setText((prev) => prev + delta);
Without the unmount abort, navigating away mid-stream leaves the fetch running and the socket open until the model finishes—wasting bandwidth and potentially throwing later. The mounted ref avoids the “can’t perform state update on unmounted component” error in older React versions.
Race conditions with rapid clicks
If a user clicks Send, then Stop, then Send within milliseconds, the first cancel() in send aborts the first controller, but the second send creates a new one. Because abortRef is overwritten synchronously, the first stream’s finally may run after the second starts. Guard with a local controller variable rather than only the ref:
const controller = new AbortController();
abortRef.current = controller;
// later in finally:
if (abortRef.current === controller) abortRef.current = null;
Step 5: Verify the cancellation works
Run a local mock streamer to observe disconnects. This Python server streams numbered frames and breaks when the client closes the socket.
import asyncio
from aiohttp import web
async def handle(request):
response = web.StreamResponse()
await response.prepare(request)
for i in range(100):
if request.transport.is_closing():
break
await response.write(f"data: {{\"choices\":[{{\"delta\":{{\"content\":\"{i} \"}}}}]}}\n\n".encode())
await asyncio.sleep(0.1)
await response.write(b"data: [DONE]\n\n")
return response
app = web.Application()
app.router.add_post('/v1/chat/completions', handle)
web.run_app(app, port=8080)
Point the hook at http://localhost:8080/v1/chat/completions. Open Chrome DevTools Network tab, click Send, then Stop. The request row turns red with “(canceled)”. The Python loop breaks because request.transport.is_closing() detects the closed socket. That confirms your react abortcontroller llm streaming cancel pipeline is solid.
For production, watch your provider logs or usage dashboard. If you still see tokens generated after client cancel, the signal isn’t reaching the fetch—check that signal: controller.signal is actually passed and not shadowed by a config object. With proper abort forwarding, the metered token count reflects only tokens delivered before disconnect.
Quick unit check
You can simulate a stream in tests with a ReadableStream:
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'));
controller.close();
},
});
// attach to a Response and assert setText receives "hi" before abort
This catches parser regressions without a live model.
Following these steps gives you a chat UI where Stop is instantaneous, tokens stop flowing, and no orphaned connections linger in the background.