EXPLAINER

Chatbot State and Conversation UI in React

Model chatbot state as a messages array with roles, a pending flag, and optional streaming buffers. Keep the API key off the client and treat each turn as append-only history you send to your backend.

Short answer: React state for a chatbot is mainly a messages array (who said what), pending, and error — not a place to store API secrets.

What is it?

Chatbot state is the data that makes the UI coherent across turns: the ordered list of messages, whether a reply is in flight, any partial streamed text, and errors. React holds that state in component state or a small store. The model itself is stateless; your UI and server re-send history as needed (Agent Memory: Short-Term, Long-Term and Beyond, What is a Context Window?).

Why it matters

Bugs in state show up as duplicated messages, lost drafts, or spinners that never clear. Clear state also clarifies security: tokens and API keys never belong in useState on the client.

How to do it

Recommended fields:

  • messages: { id, role: 'user'|'assistant'|'system', content }[]
  • pending: boolean
  • error: string | null
  • draft: string for the composer
  • optional streamingId / partial content while streaming

Patterns:

  1. Append user message optimistically, then append assistant on success.
  2. On failure, keep the user message, set error, clear pending.
  3. When talking to your API, send only the history slice you intend (trim for context limits on the server).
  4. Do not store MODEL_API_KEY in React state or localStorage.
  5. For streaming, update the last assistant message's content as tokens arrive (Streaming AI Responses for Chat UIs).

Example

const initial = { messages: [], pending: false, error: null, draft: "" };

function reducer(state, action) {
  switch (action.type) {
    case "draft": return { ...state, draft: action.value };
    case "send":
      return {
        ...state,
        draft: "",
        pending: true,
        error: null,
        messages: [...state.messages, { id: action.id, role: "user", content: action.text }],
      };
    case "receive":
      return {
        ...state,
        pending: false,
        messages: [...state.messages, { id: action.id, role: "assistant", content: action.text }],
      };
    case "fail":
      return { ...state, pending: false, error: action.error };
    default: return state;
  }
}

Wire the reducer to the UI in Building AI Interfaces with React.

When to use it

Use an explicit messages array as soon as you have more than one turn. Lift state if multiple components (sidebar history, main thread) need the same conversation.

Practical notes

As conversations grow, consider pagination or virtualised lists so thousands of DOM nodes do not appear at once. Store a client-generated 'id' on each message so streaming updates can target the correct row. If you support editing a past user message and regenerating, treat that as a branch: either truncate following messages or keep an alternate thread id. Separate "display history" from "model history" when the server strips tool traces the user should not see. Persist conversations to your backend (Databases for AI Applications) keyed by user, and reload them into the same state shape on refresh. Never stash OAuth tokens or vendor keys beside the messages array in 'localStorage'.

Common mistakes

  • Mutating the messages array in place so React skips re-renders.
  • Putting the API key in context "for convenience".
  • Sending the entire unbounded history forever without server-side trimming.
  • Losing the draft when an error occurs.