Chat - Real-time thread sync
Push add, update, and remove events from your backend to keep conversations and messages in sync.
Collection events drive structural changes to the message and conversation lists, in contrast to the typing, presence, and read-state events covered in the basic Realtime demo:
message-added—a new message appears in the thread.message-updated—an existing message is modified.message-removed—a message is deleted from the thread.conversation-added—a new conversation appears in the sidebar.conversation-updated—a conversation title or metadata changes.conversation-removed—a conversation is deleted (active conversation resets if it matched).
Key concepts
Dispatching collection events
Push events through the onEvent callback provided by subscribe():
// Add a message from another user
onEvent({
type: 'message-added',
message: {
id: 'msg-new',
conversationId: 'support',
role: 'assistant',
parts: [{ type: 'text', text: 'New message from the backend.' }],
status: 'sent',
},
});
// Remove a conversation
onEvent({
type: 'conversation-removed',
conversationId: 'old-thread',
});
Active conversation reset
When a conversation-removed event arrives and the removed conversation is the active one, the runtime resets activeConversationId to undefined.
The UI can respond by showing a placeholder or selecting the next conversation.
The demo below shows collection events driving thread and sidebar updates in real time:
Realtime collection sync
Message and conversation events can reshape the thread without a manual refetch.
2
Support
4
Welcome to the headless runtime demo.
Show me the smallest possible chat setup.
The controlled API keeps public state array-first.
That is the behavior we want to document.
import * as React from 'react';
import { ChatProvider, useChat } from '@mui/x-chat/headless';
import type {
ChatAdapter,
ChatConversation,
ChatMessage,
ChatRealtimeEvent,
} from '@mui/x-chat/headless';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import {
cloneConversations,
cloneMessages,
demoConversations,
demoThreads,
demoUsers,
} from 'docs/data/chat/core/examples/shared/demoData';
function createRealtimeSyncAdapter() {
let onEventRef: ((event: ChatRealtimeEvent) => void) | null = null;
return {
adapter: {
async sendMessage() {
return new ReadableStream({
start(controller) {
controller.close();
},
});
},
subscribe({ onEvent }) {
onEventRef = onEvent;
return () => {
onEventRef = null;
};
},
} satisfies ChatAdapter,
emit(event: ChatRealtimeEvent) {
onEventRef?.(event);
},
};
}
function RealtimeThreadSyncInner({
emit,
}: {
emit: (event: ChatRealtimeEvent) => void;
}) {
const { conversations, messages, activeConversationId, setActiveConversation } =
useChat();
const messageCounter = React.useRef(0);
const conversationCounter = React.useRef(0);
const activeConversation = conversations.find(
(conversation) => conversation.id === activeConversationId,
);
const addMessage = () => {
if (!activeConversationId) {
return;
}
messageCounter.current += 1;
const nextMessage: ChatMessage = {
id: `live-message-${messageCounter.current}`,
conversationId: activeConversationId,
role: 'assistant',
author: demoUsers.agent,
status: 'sent',
parts: [
{
type: 'text',
text: `Realtime message ${messageCounter.current} arrived for ${activeConversationId}.`,
},
],
};
emit({ type: 'message-added', message: nextMessage });
};
const updateLastMessage = () => {
const target = [...messages]
.reverse()
.find((message) => message.role === 'assistant');
if (!target) {
return;
}
emit({
type: 'message-updated',
message: {
...target,
parts: [
{
type: 'text',
text: 'This assistant message was patched by a realtime update.',
},
],
},
});
};
const removeLastMessage = () => {
const target = [...messages]
.reverse()
.find((message) => message.role === 'assistant');
if (!target) {
return;
}
emit({ type: 'message-removed', messageId: target.id });
};
const addConversation = () => {
conversationCounter.current += 1;
const conversation: ChatConversation = {
id: `live-conversation-${conversationCounter.current}`,
title: `Escalation ${conversationCounter.current}`,
subtitle: 'Created by realtime sync',
readState: 'unread',
unreadCount: 1,
participants: [demoUsers.alice, demoUsers.agent],
};
emit({ type: 'conversation-added', conversation });
};
const renameActiveConversation = () => {
if (!activeConversation) {
return;
}
emit({
type: 'conversation-updated',
conversation: {
...activeConversation,
title: `${activeConversation.title} (live)`,
subtitle: 'Renamed by realtime event',
},
});
};
const removeActiveConversation = () => {
if (!activeConversationId) {
return;
}
emit({ type: 'conversation-removed', conversationId: activeConversationId });
};
return (
<Paper variant="outlined" sx={{ overflow: 'hidden', width: '100%' }}>
{/* Header */}
<Box
sx={{
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Realtime collection sync
</Typography>
<Typography variant="body2" color="text.secondary">
Message and conversation events can reshape the thread without a manual
refetch.
</Typography>
</Box>
{/* Conversation selector */}
<Stack
direction="row"
spacing={1}
sx={{
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
flexWrap: 'wrap',
}}
>
{conversations.map((conversation) => (
<Chip
key={conversation.id}
label={conversation.title ?? conversation.id}
variant={
conversation.id === activeConversationId ? 'filled' : 'outlined'
}
color={conversation.id === activeConversationId ? 'primary' : 'default'}
onClick={() => {
void setActiveConversation(conversation.id);
}}
/>
))}
</Stack>
{/* Stats */}
<Stack
direction="row"
spacing={1}
sx={{ px: 2, py: 1.5, borderBottom: 1, borderColor: 'divider' }}
>
{[
{ label: 'Conversations', value: conversations.length },
{ label: 'Active', value: activeConversation?.title ?? 'none' },
{ label: 'Messages', value: messages.length },
].map((stat) => (
<Paper
key={stat.label}
variant="outlined"
sx={{ px: 1.5, py: 0.75, flex: 1, textAlign: 'center' }}
>
<Typography variant="caption" color="text.secondary">
{stat.label}
</Typography>
<Typography variant="body2" noWrap sx={{ fontWeight: 700 }}>
{stat.value}
</Typography>
</Paper>
))}
</Stack>
{/* Action buttons */}
<Stack
direction="row"
spacing={1}
sx={{
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
flexWrap: 'wrap',
rowGap: 1,
}}
>
<Button
size="small"
variant="outlined"
onClick={addMessage}
disabled={!activeConversationId}
>
Add message
</Button>
<Button
size="small"
variant="outlined"
onClick={updateLastMessage}
disabled={messages.length === 0}
>
Update last assistant message
</Button>
<Button
size="small"
variant="outlined"
onClick={removeLastMessage}
disabled={messages.length === 0}
>
Remove last assistant message
</Button>
<Button size="small" variant="outlined" onClick={addConversation}>
Add conversation
</Button>
<Button
size="small"
variant="outlined"
onClick={renameActiveConversation}
disabled={!activeConversationId}
>
Rename active conversation
</Button>
<Button
size="small"
variant="outlined"
onClick={removeActiveConversation}
disabled={!activeConversationId}
>
Remove active conversation
</Button>
</Stack>
{/* Messages */}
<Box
sx={{
p: 2,
minHeight: 300,
maxHeight: 400,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{messages.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mt: 8 }}
>
Select or create a conversation. Removing the active thread clears the
message view.
</Typography>
) : (
messages.map((message) => {
const isUser = message.role === 'user';
return (
<Box
key={message.id}
sx={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
}}
>
<Paper
elevation={0}
sx={{
px: 2,
py: 1,
maxWidth: '80%',
bgcolor: isUser ? 'primary.main' : 'grey.100',
color: isUser ? 'primary.contrastText' : 'text.primary',
borderRadius: 3,
}}
>
<Typography
variant="caption"
sx={{
fontWeight: 700,
color: isUser ? 'primary.contrastText' : 'text.secondary',
}}
>
{message.author?.displayName ?? message.role}
</Typography>
{message.parts.map((part, index) => (
<Typography
variant="body2"
key={`${message.id}-${part.type}-${index}`}
>
{part.type === 'text' ? part.text : null}
</Typography>
))}
</Paper>
</Box>
);
})
)}
</Box>
</Paper>
);
}
export default function RealtimeThreadSyncHeadlessChat() {
const { adapter, emit } = React.useMemo(() => createRealtimeSyncAdapter(), []);
return (
<ChatProvider
adapter={adapter}
initialConversations={cloneConversations(demoConversations.slice(0, 2))}
initialMessages={[
...cloneMessages(demoThreads.support),
...cloneMessages(demoThreads.product),
]}
initialActiveConversationId="support"
>
<RealtimeThreadSyncInner emit={emit} />
</ChatProvider>
);
}
Key takeaways
- Collection events (
*-added,*-updated,*-removed) drive structural changes to the store. - The runtime handles normalization—adding, replacing, or removing records in the ID maps.
- Active conversation automatically resets when its conversation is removed.
- These events work alongside the streaming lifecycle—you can push messages while a stream is active.
See also
- See Realtime for the full event type reference.
- See Realtime—Typing, presence, and read state for details.
- See Adapters for the
subscribe()method reference.