#Next.js#React#TypeScript

React AI Conversation State Contracts: Separating Messages, Runs, and Persistence

Multi-turn AI conversations need more than a single messages array. This article separates transient browser state, server-side conversation facts, generation runs, and idempotent protocols to prevent conversation mix-ups, duplicate messages, and data loss on refresh.

Key Points

  • Topic Focus: Next.js、React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Many AI conversation prototypes have only one messages array: append user text on submission, then continuously update the last item as streaming data arrives. That is sufficient for a single-window demonstration, but once refreshes, multiple tabs, regeneration, tool execution, or slow concurrent requests appear, the array cannot answer key questions: Which message has been persisted? Which generation is writing it? Which version can serve as context for the next turn? Are late events still valid?

A production design should first assign state ownership, then choose a React state library. The server database stores auditable facts; the browser stores transient interactions such as input drafts, scroll positions, and expanded panels; the URL stores only shareable conversation identifiers and view parameters. Do not assume that a visitor owns a conversation simply because conversationId appears in the URL. Every read, message append, and generation start must be reauthorized on the server according to the current identity.

Conversations, Messages, and Runs Are Three Objects

conversation represents the conversation container and should at least include its owner, title, version, creation time, and retention policy. message represents an immutable semantic record containing messageId, role, content version, state, and order. run represents one model execution, containing runId, the input message set, model configuration, start time, end reason, and target assistant message.

Once runs are modeled separately, "regenerate" no longer needs to overwrite an old message: it creates a new run and a candidate message version. When a user chooses a version to continue the conversation, the server explicitly records the active branch. Tool calls can likewise be structured steps under a run rather than masquerading as a string. This state-machine approach aligns with Agent Runtime State Machines, but the frontend receives only the safe fields needed for display.

Message ordering must not depend on client time. The server can assign a monotonically increasing sequence, or use explicit parent-message relationships. Timestamps are for display and auditing, not for resolving concurrency. Otherwise, when two tabs send messages at the same time, clock skew and submission order can make the context branch unpredictably.

Optimistic UI Still Needs an Idempotent Identity

After a user presses send, React can immediately insert a pending message, but the request must include a client-generated clientMessageId. The server uses "user + conversation + clientMessageId" as a unique key: the first request creates the record, while network retries return the same result. Without that constraint, mobile network retransmission or double-clicking produces two messages with identical content but separate identities, and triggers model charges twice.

The server response returns the formal messageId, runId, and the conversation's new version. The client reducer reconciles through clientMessageId rather than guessing from the final item in the array. Each stream event carries both runId and an increasing event sequence number; if an event belongs to an old run, or its sequence number is not greater than the last applied number, ignore it. This prevents late data from contaminating a new answer after regeneration.

Concurrent updates can use the conversation version as an optimistic lock. The client submits the expectedVersion it observed; within a transaction, the server confirms the version, appends the message, and increments the version. On conflict, return a recognizable conflict code so the client can fetch the delta and ask the user to confirm, rather than silently stitching two branches together. For local React state management choices, see React State Management, but whether using a reducer, Context, or an external store, none can replace server-side concurrency constraints.

Separate Server Snapshots From UI Drafts

An initial conversation snapshot obtained through server rendering is suitable as confirmed data. Input drafts, attachments currently being uploaded, the selected model, and the auto-scroll toggle are local state and should not be written to the database on every keystroke. If drafts must span devices, model them separately and give them an expiration time rather than mixing them into formal messages.

When switching conversations, giving the conversation workspace a stable React key can intentionally reset the input and temporary selections; global preferences that need to persist belong at a higher level. React's rules for preserving state based on component position and keys must be designed deliberately, otherwise a draft or error message from conversation A may remain after switching to conversation B. At the same time, long message lists must use message IDs as keys, never array indexes.

Browser cache is not the final source of truth. After refreshing or reconnecting, the client requests deltas using the last confirmed event sequence number or message version; the server returns missing records and the current active run. If a run is still executing, the client resubscribes; if it has ended, persisted results correct the local temporary text. For multi-turn context trimming, summaries, and factual memory, continue with State Management for Multi-turn Conversations.

Privacy, Deletion, and Observability Are Also State Contracts

The server must distinguish among "hide from the current interface," "user-requested deletion," and "entering the compliance retention period." Deleting a conversation must also handle attachments, vector indexes, run traces, and caches; it is not enough to delete message rows. When a client signs out, it should clear local drafts, query caches, and cross-tab broadcast content so the next person using the same device cannot see leftovers.

Logs need only record IDs, state transitions, latency, and error codes; do not log complete prompts by default. Metrics should monitor the duplicate-submission deduplication rate, version-conflict rate, orphaned runs, refresh recovery success rate, and the number of cross-conversation events discarded. Tests should simulate double-click sending, concurrent activity in two tabs, refreshing during streaming, old events arriving after regeneration, permission revocation, and reconnecting after deletion. Only when the state model remains consistent in these scenarios does it truly support multi-turn conversations, rather than merely making a message list look continuous.

Related

Related Content