A React AI chat interface needs to know which request owns each update. Otherwise a delayed chunk from an old response can overwrite a newer answer, and a cancelled request can leave the screen looking complete. I separate the response state, the transport and any action that can change store data.
1. Define the example before copying the code
My Dahlia AI case study documents the product problem: a storefront concierge with an operator workflow and human handoff. Its installed widget is vanilla JavaScript. The React modules below are teaching examples, not a claim about Dahlia's production implementation.
The examples assume a same-origin endpoint that returns text/plain; charset=utf-8. They do not parse server-sent events, newline-delimited JSON or a model provider's event protocol. If your backend emits one of those formats, implement its parser before sending text to the reducer.
Transport chunks are also not necessarily words or model tokens. A network read can contain several pieces of text or part of a multibyte character. Naming the action CHUNK helps preserve that distinction when debugging the interface.
2. Give one response a clear state contract
React's useReducer reference describes a pure function that computes the next state from the previous state and an action. I would use that boundary for response status and content, leaving requests and abort controllers outside it.
This module manages the active response only. A surrounding conversation component owns the submitted user messages and completed replies. A new START intentionally replaces the active response, so archive any reply you want to retain before starting another.
export const initialResponse = {
requestId: null,
status: 'idle',
reply: '',
error: null,
};
export function responseReducer(state, action) {
if (action.type === 'START') {
return {
requestId: action.id,
status: 'streaming',
reply: '',
error: null,
};
}
if (action.id !== state.requestId || state.status !== 'streaming') {
return state;
}
switch (action.type) {
case 'CHUNK':
return { ...state, reply: state.reply + action.text };
case 'DONE':
return { ...state, status: 'complete' };
case 'STOP':
return { ...state, status: 'stopped' };
case 'FAIL':
return { ...state, status: 'error', error: action.message };
default:
return state;
}
}Use it in a component with useReducer(responseReducer, initialResponse). Every asynchronous callback must carry the ID captured when its request began. Reading a mutable “latest ID” inside an old callback would wrongly grant that callback ownership of the new response.
The status guard also rejects late chunks after completion, cancellation or failure. Preserve the partial text for a stopped response, but label it as incomplete. A partial answer should not visually become a confirmed result just because its loading indicator disappeared.
3. Read plain text without breaking character boundaries
The following browser module accepts the request's AbortSignal and a callback for decoded text. Its endpoint must implement the plain-text contract above. Backend authentication, rate limits and model credentials remain server responsibilities.
export async function streamTextReply({ messages, signal, onText }) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/plain',
},
body: JSON.stringify({ messages }),
signal,
});
const mime = (response.headers.get('content-type') || '')
.split(';')[0].trim().toLowerCase();
if (!response.ok || !response.body || mime !== 'text/plain') {
if (response.body) await response.body.cancel().catch(() => {});
throw new Error('Expected a successful plain-text chat response');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
try {
while (true) {
signal?.throwIfAborted();
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text) onText(text);
}
signal?.throwIfAborted();
const tail = decoder.decode();
if (tail) onText(tail);
} finally {
try {
await reader.cancel();
} catch {
// An aborted or failed stream can already be closed.
}
reader.releaseLock();
}
}MDN's readable stream guide explains response readers and consuming chunks. Keeping a decoder across reads lets it retain partial character bytes; the final decode flushes any remaining input. Fatal decoding makes malformed UTF-8 an error instead of silently accepting damaged text.
Render the accumulated reply as text. Do not insert streamed model output directly as HTML. If you add rich text later, that is a separate rendering and sanitization decision, not something the transport module handles.
4. Wire cancellation before enabling retries
The caller creates a fresh AbortController and request ID for each submission. Dispatch START, then call the transport with that controller's signal. Its callback dispatches CHUNK with the captured ID; successful completion dispatches DONE.
On a Stop action, dispatch STOP for the current ID and abort its controller. On failure, distinguish an intentional abort from an unexpected error before choosing STOP or FAIL. Abort the active request on component unmount, and never reuse an already aborted signal for a retry.
I would keep the controller in a ref and prevent duplicate submissions synchronously at the event boundary. A disabled button is useful feedback, but it is not the whole concurrency policy. If the design allows replacing a request, abort the previous one and preserve any conversation content that must remain visible.
| Scenario | Required result |
|---|---|
| Old request sends a late chunk | New reply stays unchanged |
| Shopper stops generation | Partial reply is marked stopped |
| Response has a different content type | Visible failure, not raw protocol text |
| UTF-8 character crosses chunk boundaries | Character is reconstructed correctly |
| Stream ends with invalid UTF-8 | Response fails rather than claiming completion |
| Network fails after some text | Partial reply remains visibly incomplete |
The custom hooks guide covers related ownership and cleanup issues. Test these cases using controlled streams before connecting the actual backend. A successful happy-path demo does not exercise request replacement or teardown.
5. Keep store actions outside the reply renderer
An answer recommending a product and a request to change a cart have different consequences. I would render text first and route structured action proposals through a separate application boundary. A model naming an action does not establish that the shopper requested or authorized it.
An allowlist only identifies recognized actions. Each action still needs argument validation, tenant and resource checks, current product eligibility and any required user confirmation. Server-side writes need their own authorization and duplicate-request handling; hiding a button does not enforce those checks.
OWASP's prompt injection prevention guidance discusses validating tool use and separating untrusted model content from privileged operations. Treat retrieved descriptions and conversation text as data. Neither should grant new permissions to a store action.
Start by testing the response modules with text-only fixtures and the table above. Then review the installation boundary in the embeddable widget guide. Add each state-changing action only after its independent validation and authorization path is defined.