AI-powered search and chat interfaces face a unique frontend challenge: responses arrive as streams of tokens over seconds rather than as single JSON payloads in milliseconds. A traditional request-response model would leave users staring at a loading spinner for 5-15 seconds while the LLM generates its full response. Streaming hooks solve this by consuming Server-Sent Events (SSE) and incrementally building up the response state — tokens appear as they’re generated, creating the familiar “typewriter” effect that users expect from modern AI interfaces.
A typical AI frontend codebase implements three specialized streaming hooks — useAnswerStream, useChatStream, and useCitations — each managing a different aspect of the AI response lifecycle. These hooks handle the complexity of SSE connection management, incremental state accumulation across multiple response phases (classification, reasoning, search hits, answer tokens, follow-up questions), error recovery, and cleanup on unmount or navigation. The hooks abstract this complexity so consuming components can simply read answer, citations, and isStreaming from the hook’s return value.
The streaming response from the backend is not a simple sequence of text tokens. It’s a structured protocol where different event types carry different payloads: a classification event indicates the query type, reasoning events contain chain-of-thought steps, hit events deliver search results as they’re ranked, token events carry individual answer tokens, and followup events suggest related questions. The hooks must parse these event types and route each payload to the correct piece of accumulated state — a state machine problem that maps naturally to useReducer.
SSE is a browser-native protocol for receiving a unidirectional stream of events from a server over a single HTTP connection. Unlike WebSockets, SSE uses standard HTTP (making it compatible with proxies and CDNs), automatically reconnects on disconnection, and supports event typing via the event: field. The browser’s EventSource API handles SSE natively, but the hooks in this pattern use fetch with ReadableStream instead, because EventSource doesn’t support POST requests (needed to send the query body) or custom headers (needed for authentication).
A streaming AI response is built incrementally: each token event appends a word or fragment to the growing answer string, each hit event adds a search result to the results array, each reasoning event extends the chain-of-thought log. The hook accumulates these fragments into a complete response object over the lifetime of the stream. This is fundamentally different from typical React state management where each setState replaces the old value — here, every update builds on the previous state, and the order of events matters.
The AI backend emits events in a defined sequence of phases: first classification (what kind of query is this?), then reasoning (chain-of-thought steps), then hit events (search results being ranked), then token events (the generated answer), and finally followup events (suggested next questions). Each phase activates a different part of the UI — the classification badge, the reasoning drawer, the results list, the streaming answer text, and the follow-up chips. The hook tracks which phase is active so the UI can show appropriate progress indicators.
If the user navigates away, submits a new query, or the component unmounts while streaming is in progress, the hook must close the SSE connection and stop processing events. Without cleanup, the stream continues consuming bandwidth and CPU in the background, and state updates to an unmounted component trigger React warnings. The hooks use AbortController to cancel the fetch request and a useRef-based “cancelled” flag to prevent state updates after cleanup.
An LLM can emit tokens faster than React can re-render — if each token triggers a synchronous state update and re-render, the UI freezes because the render queue fills up faster than the browser can paint frames. The hooks batch incoming tokens using requestAnimationFrame: tokens are accumulated in a mutable buffer outside React state, and on each animation frame, the buffer is flushed into state as a single update. This limits re-renders to ~60/second regardless of token rate, keeping the UI responsive.
Since the browser’s EventSource API only supports GET requests, the hooks use fetch with manual stream parsing to make POST requests with SSE responses.
// Parse SSE events from a ReadableStream
async function* parseSSE(
response: Response,
signal: AbortSignal
): AsyncGenerator<SSEEvent> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (signal.aborted) return;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!; // keep incomplete line in buffer
let eventType = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
data += line.slice(5).trim();
} else if (line === '') {
// Empty line = end of event
if (data) {
yield { type: eventType, data: JSON.parse(data) };
eventType = 'message';
data = '';
}
}
}
}
} finally {
reader.releaseLock();
}
}
interface SSEEvent {
type: string;
data: any;
}
This hook manages the full lifecycle of a streaming AI answer — from initiating the request to accumulating tokens and handling completion.
interface AnswerStreamState {
phase: 'idle' | 'classifying' | 'reasoning' | 'searching' | 'answering' | 'done' | 'error';
classification: string | null;
reasoning: string[];
hits: SearchHit[];
answer: string;
followUps: string[];
error: string | null;
}
type StreamAction =
| { type: 'RESET' }
| { type: 'CLASSIFICATION'; value: string }
| { type: 'REASONING_STEP'; value: string }
| { type: 'HIT'; value: SearchHit }
| { type: 'TOKENS'; value: string } // batched tokens
| { type: 'FOLLOWUP'; value: string }
| { type: 'DONE' }
| { type: 'ERROR'; error: string };
function streamReducer(state: AnswerStreamState, action: StreamAction): AnswerStreamState {
switch (action.type) {
case 'RESET':
return { phase: 'idle', classification: null, reasoning: [],
hits: [], answer: '', followUps: [], error: null };
case 'CLASSIFICATION':
return { ...state, phase: 'classifying', classification: action.value };
case 'REASONING_STEP':
return { ...state, phase: 'reasoning',
reasoning: [...state.reasoning, action.value] };
case 'HIT':
return { ...state, phase: 'searching',
hits: [...state.hits, action.value] };
case 'TOKENS':
return { ...state, phase: 'answering',
answer: state.answer + action.value };
case 'FOLLOWUP':
return { ...state, followUps: [...state.followUps, action.value] };
case 'DONE':
return { ...state, phase: 'done' };
case 'ERROR':
return { ...state, phase: 'error', error: action.error };
default:
return state;
}
}
function useAnswerStream(apiConfig: ApiConfig) {
const [state, dispatch] = React.useReducer(streamReducer, {
phase: 'idle', classification: null, reasoning: [],
hits: [], answer: '', followUps: [], error: null,
});
const abortRef = React.useRef<AbortController | null>(null);
const tokenBufferRef = React.useRef('');
const rafRef = React.useRef<number | null>(null);
// Flush batched tokens on animation frame
const flushTokens = React.useCallback(() => {
if (tokenBufferRef.current) {
const batch = tokenBufferRef.current;
tokenBufferRef.current = '';
dispatch({ type: 'TOKENS', value: batch });
}
rafRef.current = null;
}, []);
const startStream = React.useCallback(async (query: string) => {
// Cancel any in-flight stream
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
dispatch({ type: 'RESET' });
try {
const response = await fetch(`${apiConfig.endpoint}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiConfig.apiKey}`,
'Accept': 'text/event-stream',
},
body: JSON.stringify({ query }),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
for await (const event of parseSSE(response, controller.signal)) {
if (controller.signal.aborted) break;
switch (event.type) {
case 'classification':
dispatch({ type: 'CLASSIFICATION', value: event.data.label });
break;
case 'reasoning':
dispatch({ type: 'REASONING_STEP', value: event.data.step });
break;
case 'hit':
dispatch({ type: 'HIT', value: event.data });
break;
case 'token':
// Buffer tokens and flush on animation frame
tokenBufferRef.current += event.data.text;
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(flushTokens);
}
break;
case 'followup':
dispatch({ type: 'FOLLOWUP', value: event.data.question });
break;
case 'done':
// Flush any remaining buffered tokens
flushTokens();
dispatch({ type: 'DONE' });
break;
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
dispatch({ type: 'ERROR', error: err.message });
}
}
}, [apiConfig, flushTokens]);
// Cleanup on unmount
React.useEffect(() => {
return () => {
abortRef.current?.abort();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
return {
...state,
isStreaming: state.phase !== 'idle' && state.phase !== 'done' && state.phase !== 'error',
startStream,
cancel: () => abortRef.current?.abort(),
};
}
This hook correlates inline citation markers in the streamed answer text (like [1], [2]) with the search hits accumulated during the search phase.
interface Citation {
index: number;
hit: SearchHit;
spans: Array<{ start: number; end: number }>;
}
function useCitations(answer: string, hits: SearchHit[]): Citation[] {
return React.useMemo(() => {
const citations: Citation[] = [];
const regex = /\[(\d+)\]/g;
let match: RegExpExecArray | null;
const indexMap = new Map<number, Citation>();
while ((match = regex.exec(answer)) !== null) {
const idx = parseInt(match[1], 10);
if (idx < 1 || idx > hits.length) continue;
let citation = indexMap.get(idx);
if (!citation) {
citation = { index: idx, hit: hits[idx - 1], spans: [] };
indexMap.set(idx, citation);
citations.push(citation);
}
citation.spans.push({
start: match.index,
end: match.index + match[0].length,
});
}
return citations;
}, [answer, hits]);
}
A production answer interface uses useAnswerStream to power a multi-phase response display. When a user submits a query, the UI first shows a classification badge (“Factual”, “Exploratory”, “Navigational”), then reveals a collapsible reasoning panel as chain-of-thought steps stream in, then displays search result cards as they arrive, and finally renders the answer text token by token with inline citation links. Each phase transition triggers a CSS animation that draws attention to the newly active section, creating a sense of progressive disclosure that keeps the user engaged during the 5-10 second generation time.
The useChatStream variant extends the pattern for multi-turn conversations. It maintains an array of message objects, each with its own streaming state. When the user sends a follow-up question, the hook appends a new message to the array and starts streaming into it while preserving the completed state of previous messages. This requires careful management of which AbortController belongs to which message — cancelling the current stream must not corrupt the already-completed messages.
The requestAnimationFrame batching pattern was added after performance profiling revealed that unbatched token updates caused the UI to drop frames on mobile devices. The LLM backend emits tokens at 50-100 per second; without batching, each token triggered a React re-render that measured at ~4ms (including DOM diffing and paint), leaving zero headroom in the 16.67ms frame budget. Batching to animation frames reduced re-renders to ~60/second, with each render processing 1-2 accumulated tokens — well within the frame budget even on low-end devices.
Use useRef for data that changes frequently but shouldn’t trigger re-renders on every change. The token buffer is a ref because each individual token doesn’t need to be rendered — only the batched result at each animation frame. This pattern separates “write frequency” (every token) from “render frequency” (every frame).
The streaming response is a state machine with well-defined phases and transitions. Using useReducer makes the phase transitions explicit and prevents impossible states — for example, the reducer can enforce that TOKENS actions only update state when the phase is 'answering', preventing stale events from corrupting state after the stream completes.
Each call to startStream creates a new AbortController and aborts the previous one. This ensures exactly one active stream at a time without leaking connections. The controller is stored in a ref rather than state because changing it should not trigger a re-render.
EventSource API only supports GET requests with URL parameters. For AI endpoints that accept complex query bodies, you must use fetch with manual SSE parsing. Attempting to encode a full query in URL parameters hits URL length limits and leaks query content into server logs.dispatch triggers a React re-render. On a component tree with citations, markdown rendering, and syntax highlighting, each render can take 5-10ms, causing the UI to freeze. Always batch token updates to animation frames.dispatch to a dead reducer, causing React “Can’t perform a React state update on an unmounted component” warnings and wasting bandwidth.token event before classification should still accumulate the token rather than discarding it.requestAnimationFrame is called but the component unmounts before the frame fires, the callback still executes and may reference stale state. Always cancelAnimationFrame in the cleanup function.