Virtuoso Message List Controlled Mode Recipes
Controlled Mode Recipes
Section titled “Controlled Mode Recipes”When you drive the message list with the data prop, keep the message array and its scrollModifier together in a single DataWithScrollModifier<Message> state value. In larger chat integrations, wrap raw setData calls in app-owned actions that match product events.
This page shows a recipe, not a public hook. If you wrap it in an app-local hook, keep that hook private to your application. Keep names like appendIncoming or confirmLocal in your application until several integrations prove the same abstraction is broadly reusable.
import {
DataWithScrollModifier,
ScrollModifier,
scrollToBottomAlways,
scrollToBottomIfAtBottom,
VirtuosoMessageListMethods,
} from '@virtuoso.dev/message-list'
import { useCallback, useRef, useState } from 'react'
interface Message {
id: string | null
localId: string | null
text: string
delivered: boolean
}
interface MessageListContext {
expandedMessages: Map<string, boolean>
}
type MessageListState = DataWithScrollModifier<Message>
const InitialLocation: ScrollModifier = {
type: 'item-location',
location: { index: 'LAST', align: 'end' },
purgeItemSizes: true,
}
function useChatData() {
const messageListRef = useRef<VirtuosoMessageListMethods<Message, MessageListContext>>(null)
const [data, setData] = useState<MessageListState>({ data: [], scrollModifier: InitialLocation })
const [expandedMessages, setExpandedMessages] = useState(() => new Map<string, boolean>())
const replaceMessages = useCallback((messages: Message[]) => {
setData({ data: messages, scrollModifier: InitialLocation })
}, [])
const appendIncoming = useCallback((messages: Message[]) => {
setData((current) => ({
data: [...(current.data ?? []), ...messages],
scrollModifier: { type: 'auto-scroll-to-bottom', autoScroll: scrollToBottomIfAtBottom },
}))
}, [])
const appendLocal = useCallback((message: Message) => {
setData((current) => ({
data: [...(current.data ?? []), message],
scrollModifier: { type: 'auto-scroll-to-bottom', autoScroll: scrollToBottomAlways },
}))
}, [])
const confirmLocal = useCallback((localId: string, remoteMessage: Message) => {
setData((current) => ({
data: (current.data ?? []).map((message) => (message.localId === localId ? remoteMessage : message)),
scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))
}, [])
const updateStreamingMessage = useCallback((id: string, chunk: string) => {
setData((current) => ({
data: (current.data ?? []).map((message) => (message.id === id ? { ...message, text: message.text + chunk } : message)),
scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))
}, [])
const rowChromeChanged = useCallback((messageId: string, expanded: boolean) => {
setExpandedMessages((current) => new Map(current).set(messageId, expanded))
messageListRef.current?.notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })
}, [])
return {
appendIncoming,
appendLocal,
confirmLocal,
context: { expandedMessages },
data,
messageListRef,
replaceMessages,
rowChromeChanged,
updateStreamingMessage,
}
}Use replaceMessages when switching channels, threads, or conversations. The purgeItemSizes: true flag clears cached row sizes so the previous conversation does not distort the next one.
Use appendIncoming for remote messages. It sticks to the bottom only when the user was already at the bottom or the list was already scrolling there. If the user is reading history, keep the viewport still and show an unseen-message indicator in your app.
Use appendLocal for optimistic sends by the current user. Local sends normally force the list to the bottom so the sender can see the new message immediately.
Use confirmLocal when the server acknowledges an optimistic message. Match by localId or another client-generated id, replace the local row with the canonical server row, and treat the confirmation as an existing-row update rather than a new incoming message.
Use updateStreamingMessage when the data item itself grows, such as a streaming assistant response. The items-change modifier keeps the list pinned only if it was already pinned.
Use rowChromeChanged when row height changes without a data-array update. Examples include expansion state in context, side maps for approvals or reactions, activity loading, and other external row state. notifyItemsChanged tells the list to re-evaluate the scroll-to-bottom policy while ResizeObserver performs the measurement.
Socket Echoes
Section titled “Socket Echoes”Realtime systems often echo the current user’s optimistic message back through the same socket stream used for remote messages. Reconcile that event by identity and source before choosing a scroll policy:
- If the event confirms a local optimistic row, call
confirmLocal. - If the event is genuinely from another user, call
appendIncoming. - If the event is a local send that was not rendered optimistically, call
appendLocal.
Do not treat every socket message as remote row growth. The event source determines whether to force bottom, preserve the scrolled-up viewport, or update an existing row.
Keys And Identity
Section titled “Keys And Identity”Set computeItemKey to a stable React key. In controlled mode, also set itemIdentity when prepend or remove-from-start operations need to match items but your reducer recreates item objects.
<VirtuosoMessageList<Message, MessageListContext>
ref={messageListRef}
data={data}
context={context}
computeItemKey={({ data }) => data.id ?? `local-${data.localId}`}
itemIdentity={(item) => item.id ?? `local-${item.localId}`}
ItemContent={ItemContent}
/>