Building an AI Chat Interface in React: State, Streaming, and Safe Actions

Building an AI Chat Interface in React: State, Streaming, and Safe Actions

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.

chatReducer.js
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.

streamReply.js
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 }));
  }
}
wiring it to the reducer
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.

safe action dispatch
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.
Chat interface responsibilities
LayerOwnsKeep out
ReducerStatus, messages, errorsNetwork and side effects
Stream loopReading tokens, appending textUI decisions
Action layerRunning allowlisted handlersTrusting raw model output
ServerLLM calls, auth, sensitive writesBusiness 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.

Back to Articles