Chat - State and store
Manage chat state with controlled and uncontrolled props on ChatProvider and a normalized internal store.
ChatProvider is the single entry point for the core runtime.
It creates the chat store, wires the adapter, and makes hooks and selectors available to every descendant component.
The following demo shows controlled state in action:
Controlled headless state
2
2
product
Document the controlled models.
The controlled API keeps public state array-first.
That is the behavior we want to document.
Configuring the runtime
Required
| Prop | Type | Description |
|---|---|---|
adapter |
ChatAdapter<Cursor> |
The transport adapter |
children |
React.ReactNode |
The UI tree |
Controlled and uncontrolled state
Each public state model supports both controlled and uncontrolled modes.
Use default* props to let the runtime own the value, or pass the value directly to control it from React state.
| Model | Controlled prop | Default prop | Change callback |
|---|---|---|---|
| Messages | messages |
initialMessages |
onMessagesChange |
| Conversations | conversations |
initialConversations |
onConversationsChange |
| Active conversation | activeConversationId |
initialActiveConversationId |
onActiveConversationChange |
| Composer value | composerValue |
initialComposerValue |
onComposerValueChange |
Callbacks
| Prop | Type | Description |
|---|---|---|
onToolCall |
(payload: ChatOnToolCallPayload) => void |
Called when a tool invocation state changes |
onFinish |
(payload: ChatOnFinishPayload) => void |
Called when a stream finishes, aborts, or fails |
onData |
(part: ChatDataMessagePart) => void |
Called when a data-* chunk arrives |
onError |
(error: ChatError) => void |
Called when any runtime error surfaces |
Configuration
| Prop | Type | Default | Description |
|---|---|---|---|
streamFlushInterval |
number |
16 |
Milliseconds between batched delta flushes |
partRenderers |
ChatPartRendererMap |
{} |
Custom renderers for message part types |
storeClass |
ChatStoreConstructor |
ChatStore |
Custom store class for advanced subclassing |
Controlled vs uncontrolled
Start uncontrolled
When prototyping or when the runtime can own the data, use default* props:
<ChatProvider
adapter={adapter}
initialActiveConversationId="support"
initialMessages={initialMessages}
>
<MyChat />
</ChatProvider>
The runtime manages the state internally and feeds it to hooks automatically.
Move to controlled
When you need to own the data externally—for example, to sync with a global store or persist across navigation—pass the state directly:
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [activeId, setActiveId] = React.useState<string | undefined>('support');
<ChatProvider
adapter={adapter}
messages={messages}
onMessagesChange={setMessages}
activeConversationId={activeId}
onActiveConversationChange={setActiveId}
>
<MyChat />
</ChatProvider>;
The runtime still streams, normalizes, and derives selectors—you own the source of truth.
You can switch from uncontrolled to controlled at any time without changing the runtime model.
Normalized internal state
The store keeps data in a normalized shape for efficient streaming and updates:
| Internal field | Type | Description |
|---|---|---|
messageIds |
string[] |
Ordered message IDs |
messagesById |
Record<string, ChatMessage> |
Message records by ID |
conversationIds |
string[] |
Ordered conversation IDs |
conversationsById |
Record<string, ChatConversation> |
Conversation records by ID |
activeConversationId |
string | undefined |
Active conversation |
typingByConversation |
Record<string, Record<string, boolean>> |
Typing state per conversation per user |
isStreaming |
boolean |
Whether a stream is active |
hasMoreHistory |
boolean |
Whether more history is available |
isLoadingHistory |
boolean |
Whether a history fetch is currently in flight (initial page or older messages) |
historyCursor |
Cursor | undefined |
Pagination cursor for history loading |
composerValue |
string |
Current draft text |
composerIsComposing |
boolean |
Whether an IME composition session is active |
composerAttachments |
ChatDraftAttachment[] |
File attachments in the draft |
error |
ChatError | null |
Current error state |
activeStreamAbortController |
AbortController | null |
Controller for aborting the active stream |
This normalization is why streaming updates are efficient—updating one message does not require rebuilding the entire thread array.
Error model
Runtime errors use the ChatError type:
interface ChatError {
code: string; // machine-readable error code
message: string; // human-readable description
source: ChatErrorSource; // where the error originated
recoverable: boolean; // whether the runtime can continue
retryable?: boolean; // whether the failed operation can be retried
details?: Record<string, unknown>; // additional context
}
type ChatErrorSource = 'send' | 'stream' | 'history' | 'render' | 'adapter';
Errors surface through:
useChat().erroruseChatStatus().erroronErrorcallback onChatProvider
Callbacks
onToolCall
Fires when a tool invocation state changes during streaming. Use it for side effects outside the message list—logging, analytics, or triggering external workflows.
interface ChatOnToolCallPayload {
toolCall: ChatToolInvocation | ChatDynamicToolInvocation;
}
onFinish
Fires when a stream reaches a terminal state (success, abort, disconnect, or error).
interface ChatOnFinishPayload {
message: ChatMessage; // the assistant message
messages: ChatMessage[]; // all messages after the stream
isAbort: boolean; // user stopped the stream
isDisconnect: boolean; // stream disconnected unexpectedly
isError: boolean; // stream ended with an error
finishReason?: string; // backend-provided reason
}
onData
Fires when a data-* chunk arrives.
Use it for transient data that should trigger app-level side effects without being persisted in the message.
Registering part renderers
Register custom renderers for message part types through the partRenderers prop:
const renderers: ChatPartRendererMap = {
'ticket-summary': ({ part }) => <div>Ticket: {part.ticketId}</div>,
};
<ChatProvider adapter={adapter} partRenderers={renderers}>
<MyChat />
</ChatProvider>;
Registered renderers are available through useChatPartRenderer(partType) inside any descendant component.
Providing a custom store class
For advanced use cases, pass a custom store class via the storeClass prop.
The class must satisfy ChatStoreConstructor<Cursor>:
interface ChatStoreConstructor<Cursor = string> {
new (parameters: ChatStoreParameters<Cursor>): ChatStore<Cursor>;
}
Use this when you need to override internal normalization, add computed state, or integrate with an external store.
See also
- Hooks for the full hook API reference.
- Selectors for store selectors and advanced subscriptions.
- Controlled state for the controlled model pattern in action.
- Streaming lifecycle for send, stream, stop, and retry callbacks.