A reliable React AI chat interface comes down to three decisions: model the conversation as a state machine, stream the response token by token into the last message, and let the model trigger only actions you have explicitly allowed.
Model Chat State as a Machine, Not Scattered Flags
The most common chat bug is contradictory state: a spinner showing while a response streams, an input enabled mid-generation, an error that never clears. That happens when status lives in three separate useState booleans. A reducer keeps the conversation in one consistent shape.
const initial = { status: 'idle', messages: [], error: null };
function chatReducer(state, action) {
switch (action.type) {
case 'SEND':
return {
...state,
status: 'streaming',
error: null,
messages: [
...state.messages,
{ role: 'user', content: action.text },
{ role: 'assistant', content: '' },
],
};
case 'TOKEN':
return { ...state, messages: appendToLast(state.messages, action.token) };
case 'DONE':
return { ...state, status: 'idle' };
case 'ERROR':
return { ...state, status: 'error', error: action.error };
default:
return state;
}
}Now the UI is a pure function of status. The input is disabled when status is streaming, the error banner shows only when status is error, and there is no way to land in an impossible combination.
Stream the Response Token by Token
Streaming is what makes a chat feel alive. Instead of waiting for the full reply, you read the response body as a stream and dispatch each chunk into the last assistant message as it arrives.
async function streamReply(messages, onToken) {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
});
if (!res.ok || !res.body) throw new Error('Chat request failed');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
onToken(decoder.decode(value, { stream: true }));
}
}async function send(text) {
dispatch({ type: 'SEND', text });
try {
await streamReply(
[...stateRef.current.messages, { role: 'user', content: text }],
(token) => dispatch({ type: 'TOKEN', token }),
);
dispatch({ type: 'DONE' });
} catch (error) {
dispatch({ type: 'ERROR', error: error.message });
}
}A few details matter: keep a ref to the latest state so the request sends the current history, pass { stream: true } to the decoder so multi-byte characters survive chunk boundaries, and always resolve the status in a finally or catch so a failed stream never leaves the UI stuck.
Let the Model Act — but Only Through an Allowlist
The real power of an AI interface is letting the model do things: show a product, add to cart, capture a lead. The danger is letting model output decide what code runs. The rule is simple — the model can request an action by name, but only handlers you have registered can ever execute.
const SAFE_ACTIONS = {
show_product: ({ handle }) => navigate('/products/' + handle),
add_to_cart: ({ variantId, qty = 1 }) => cart.add(variantId, qty),
capture_lead: ({ email }) => leads.save(email),
};
function runAction(action) {
const handler = SAFE_ACTIONS[action.name];
if (!handler) return; // anything off the allowlist is ignored
try {
handler(action.args || {});
} catch (err) {
console.warn('Action failed:', action.name, err);
}
}- Keep the allowlist tiny and explicit; unknown action names are silently ignored.
- Validate every argument before using it — never pass model output straight into navigation, cart, or network calls.
- Do anything sensitive (payments, account changes) on the server with its own auth, not from the widget.
- Log requested actions so you can see what the model tries to do in real conversations.
| Layer | Owns | Keep out |
|---|---|---|
| Reducer | Status, messages, errors | Network and side effects |
| Stream loop | Reading tokens, appending text | UI decisions |
| Action layer | Running allowlisted handlers | Trusting raw model output |
| Server | LLM calls, auth, sensitive writes | Business secrets in the bundle |
Final Take
A good AI chat interface is mostly disciplined state management with a thin, safe bridge to the model. The reducer keeps the UI honest, streaming makes it feel fast, and the allowlist keeps it from doing anything you did not intend.
Start with those three layers and the LLM becomes the easy, swappable part — the interface around it is what users actually trust.