message-list について
@virtuoso.dev/message-listは、人とAIのチャット向け仮想リストです。ストリーミング返信、新着メッセージ、履歴の追加に応じたスクロール制御を備えています。
主な機能
- 01チャットメッセージの仮想化表示
- 02更新内容に応じたスクロール制御
- 03履歴追加時の表示位置維持
- 04ストリーミング返信への対応
- 05スクロール位置のフックとAPI
活用シーン
- 01チャット画面の構築
- 02ストリーミング返信の表示維持
- 03表示位置を保った過去メッセージの読み込み
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ virtuoso-dev/ skills --skill message-listインストール後、Claude Code で/message-listを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/10.smooth-scrolling.md11.testing.md15.imperative-data-api.md19.resize-observer-errors.md20.scroll-modifier.md21.controlled-mode-recipes.md4.headers-footers.md5.context.md6.item-keys.md7.hooks.md8.custom-scrollbar.md80.licensing.md9.scrolling-to-item.mdREADME.mdreferences/2.tutorial/01.intro.md02.message-list.md03.loading-older-messages.md04.scroll-to-bottom-button.md05.receive-messages.md06.send-messages.md07.multiple-channels.mdreferences/3.examples/01.messaging.md02.ai-chatbot.md03.gemini.md04.reactions.md05.date-separators.md06.scroll-to-reply.md07.grouped-messages.md詳細
@virtuoso.dev/message-list
VirtuosoMessageList は、人間同士や AI とのチャット向けに特化した仮想化リストです。最下部への追従、スクロール位置が飛ぶことなく伸びるストリーミング応答、表示位置を維持した履歴の先頭への追加、スクロール位置の追跡に対応しています。会話 UI を構築する際は通常の Virtuoso の代わりに使用してください。これらの動作は個別に組み立てる必要はなく、あらかじめ組み込まれています。
ライセンス
このパッケージは商用(年額、開発者ごと)です。すべてのインスタンスを VirtuosoMessageListLicense でラップする必要があります。
<VirtuosoMessageListLicense licenseKey={licenseKey}>
<VirtuosoMessageList ... />
</VirtuosoMessageListLicense>
空の licenseKey="" は、30 日間の非本番環境での評価利用向けライセンスです。開発環境ではコンソールにリマインダーを表示しながらレンダリングを続けますが、本番環境で有効なキーがない場合はエラーをレンダリングします。検証はローカルで行われ、ネットワークリクエストは発生しません。このパッケージをプロジェクトに導入する際は、ユーザーにこの点を伝えてください。キーは https://virtuoso.dev/pricing/ から入手できます。通常のクライアント側環境設定を読み込む、アプリケーションレベルのラッパーを使用してください。
基本的な考え方:データの更新にスクロール指示を含める
個別の命令型スクロール呼び出しを行う代わりに、各データ更新に、ビューポートの反応を記述した scrollModifier を含めます。
const [data, setData] = useState<VirtuosoMessageListProps<Message, null>['data']>(() => ({
data: initialMessages,
scrollModifier: { type: 'item-location', location: { index: 'LAST', align: 'end' } },
}))
<VirtuosoMessageList<Message, null> style={{ height: '100%' }} data={data} computeItemKey={({ data }) => data.key} ItemContent={ItemContent} />
ItemContent は { data, index, context } プロパティを受け取るコンポーネントです(react-virtuoso のような位置引数ではありません)。ローカル状態をクロージャで参照する必要がない限り、親のレンダー関数の外で定義してください。
外部の行状態はデータの更新とは異なります。context、サイドマップ、展開状態、リアクション、承認、ローディングフラグの変更によってデータ配列が変わらずに行の高さが変化する場合は、その状態を更新した後、メッセージリストの ref に対して notifyItemsChanged({ scrollToBottom }) を呼び出してください。
スクロール修飾子のリファレンス
| 修飾子 | 使用する場面 |
|---|---|
{ type: 'item-location', location, purgeItemSizes? } | 初回読み込みまたはチャンネル切り替え時。purgeItemSizes: true は項目が異なる場合にキャッシュされたサイズをクリアします |
{ type: 'auto-scroll-to-bottom', autoScroll } | 新しいメッセージを末尾に追加する場合。コールバックは false、動作、true、または項目の位置を返せます |
'prepend' | 古いメッセージを先頭に追加する場合。現在のメッセージの表示位置を維持します |
{ type: 'items-change', behavior } | ストリーミングテキストやデータ項目に保存されたリアクションなど、既存のデータ項目のサイズが変わった場合 |
'remove-from-start' / 'remove-from-end' | 表示位置を維持しながらデータを削除する場合 |
null / undefined | スクロール位置をそのままにする場合 |
コールバック形式では、動作または項目の位置を返すと、atBottom が false の場合でもスクロールします。スクロール位置を上に戻しているユーザーのビューポートを維持するには、false を返してください。
よくあるパターン
メッセージの受信(最下部にいる場合は自動スクロール)
import { scrollToBottomIfAtBottom } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, incoming],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomIfAtBottom,
},
}))
リモートメッセージには scrollToBottomIfAtBottom を使用してください。リストがすでに最下部にある、または最下部へスクロール中の場合は 'smooth' を返し、それ以外の場合は false を返します。未読メッセージ数を表示する必要がある場合は、インラインのコールバック形式を使い、false を返す前にカウンターを増やしてください。
ローカルメッセージの送信(強制的に最下部へスクロール)
import { scrollToBottomAlways } from '@virtuoso.dev/message-list'
setData((current) => ({
data: [...current.data, optimisticLocalMessage],
scrollModifier: {
type: 'auto-scroll-to-bottom',
autoScroll: scrollToBottomAlways,
},
}))
ユーザー自身のローカル操作で、新しい最後の項目をユーザーが上にスクロールしている場合でも表示するには、scrollToBottomAlways を使用してください。リアルタイムソケットから同じ楽観的メッセージが返ってきた場合は、localId または他のクライアント生成の識別子で照合し、新しいリモートメッセージではなく確認として扱ってください。
AI レスポンスのストリーミング
空のアシスタントメッセージを追加し、トークンの到着に合わせて内容を増やします。
setData((current) => ({
data: current.data.map((msg) => (msg.key === botKey ? { ...msg, text: msg.text + chunk } : msg)),
scrollModifier: { type: 'items-change', behavior: 'smooth' },
}))
items-change は、メッセージの内容が増えている間、ユーザーが上にスクロールしている場合に表示位置を飛ばすことなく、ビューを下部に固定します。ai-chatbot と gemini を参照してください(質問を上部に固定し、回答をその下にストリーミングします)。
レコードを挿入しながらのライブ末尾追従
@virtuoso.dev/message-list 1.18.0 以降では、ライブ更新によって末尾より上にレコードが挿入または並べ替えられる場合、置換アンカーと、ターゲットを変更できるスムーズスクロール動作を組み合わせます。
const liveTailScroll = () => ({
animationFrameCount: 15,
easing: (progress: number) => 1 - (1 - progress) * (1 - progress),
retarget: true,
})
<VirtuosoMessageList
ref={ref}
computeItemKey={({ data }) => data.id}
itemIdentity={(item) => item.id}
ItemContent={ItemContent}
/>
ref.current.data.batch(
() => {
ref.current.data.replace(nextData, { anchor: { index: 'LAST', position: 'preserve' } })
},
({ atBottom, scrollInProgress }) =>
atBottom || scrollInProgress
? {
index: 'LAST',
align: 'end',
behavior: liveTailScroll,
}
: false
)
アンカーは、置換中に前の最後のレコードをビューポート内の位置に保持します。ターゲットの変更により、下端が移動したときにアニメーションを再開するのではなく、実行中のアニメーションの到達先を更新します。古いリクエストと新しいリクエストの両方で、retarget: true を指定した動作を使用する必要があります。ユーザーがライブ末尾からスクロールして離れている場合は false を返します。
更新によってレコードオブジェクトが再生成される場合は、itemIdentity が安定した識別子を返す必要があります。デフォルトのオブジェクト識別では再生成されたラッパーを照合できないため、その場合、アンカーは何もしません。
外部の行状態によるサイズ変更
データ配列を変更せずに行のコンテンツのサイズが変わる場合は、notifyItemsChanged を使用します。
import { scrollToBottomIfAtBottom, type VirtuosoMessageListMethods } from '@virtuoso.dev/message-list'
const messageListRef = useRef<VirtuosoMessageListMethods<Message, MessageListContext>>(null)
setExpandedMessages((current) => new Map(current).set(messageId, expanded))
messageListRef.current?.notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })
ResizeObserver が行のサイズを測定し、notifyItemsChanged がスクロールポリシーを指定します。context 内の展開状態、リアクションや承認用のサイドマップ、アクティビティの読み込みなど、その他の外部の行状態にも使用します。
上方向へのスクロール時に古いメッセージを読み込む
<VirtuosoMessageList
onScroll={(location) => {
if (location.listOffset > -100 && !loading) {
loadOlder().then((older) => setData((current) => ({ data: [...older, ...current.data], scrollModifier: 'prepend' })))
}
}}
/>
(通常の Virtuoso とは異なり)firstItemIndex の管理は不要です。'prepend' が位置を自動的に保持します。
最下部へのスクロールボタン
StickyFooter はビューポートの下部に固定して描画されます。位置フックと組み合わせてください。
const StickyFooter = () => {
const location = useVirtuosoLocation()
const methods = useVirtuosoMethods()
if (location.bottomOffset <= 200) return null
return <button onClick={() => methods.scrollToItem({ index: 'LAST', align: 'end', behavior: 'auto' })}>▼</button>
}
チャンネルの切り替え
チャンネルごとにデータオブジェクトを保持し、replace/item-location と purgeItemSizes: true を使って切り替えます。これにより、前のチャンネルのサイズキャッシュが新しいチャンネルの表示に影響しません。multiple-channels を参照してください。
制御モードのレシピ
制御された data プロパティを使用する場合は、DataWithScrollModifier<Message> を、アプリ側で管理する replaceMessages、appendIncoming、appendLocal、confirmLocal、updateStreamingMessage、rowChromeChanged などのアクションで管理します。これはアプリコード内のレシピレイヤーであり、公式にエクスポートされているフックでも、命令型の data.* API の代替でもありません。
replaceMessages:チャンネルまたはスレッドの切り替えには、{ type: 'item-location', location: { index: 'LAST', align: 'end' }, purgeItemSizes: true }を使用します。appendIncoming:リモートメッセージにはscrollToBottomIfAtBottomを使用します。appendLocal:楽観的なローカル送信にはscrollToBottomAlwaysを使用します。confirmLocal:localIdでソケット/サーバーの確認応答と照合し、既存の行を更新します。updateStreamingMessage:既存のメッセージをマッピングし、{ type: 'items-change', behavior: 'smooth' }を使用します。rowChromeChanged:外部の行状態を更新し、notifyItemsChanged({ scrollToBottom: scrollToBottomIfAtBottom })を呼び出します。
アプリコードでこのレシピレベルの制御状態を試す前に、公開されている useVirtuosoMessageListData フックを作り出したり、推奨したりしないでください。controlled-mode-recipes を参照してください。
命令型 API
外部から ref={useRef<VirtuosoMessageListMethods<Message>>(null)} を使用するか、リスト内にレンダリングされるコンポーネントから useVirtuosoMethods() を使用します。
data.append(items, scrollToBottom?)、data.prepend(items)data.map(fn, autoscrollBehavior?)— アイテムを更新します(リアクション、編集、ストリーミング)data.mapWithAnchor(fn, anchorItemIndex)— 現在のアイテムをビューポート内の位置に保ちながら、アイテムのサイズを更新します(1.15.0 以降)data.findAndDelete(predicate)、data.deleteRange(start, length)data.replace(data, { anchor?, initialLocation?, purgeItemSizes?, suppressItemMeasure? })— レコードを置き換えます。既存のアイテムを任意で保持できます(置き換え時のアンカーには 1.18.0 以降が必要です)data.find(predicate)、data.findIndex(predicate)、data.get()、data.getCurrentlyRendered()notifyItemsChanged({ scrollToBottom })— データ配列を変更せずに、コンテキストやサイドステートの変更によってレンダリング済みの行のサイズが変わる場合に使用しますscrollToItem({ index: number | 'LAST', align, behavior })
宣言的な data プロパティと命令型の data.* メソッドは、同じリストを制御するための代替手段です。更新の競合を避けるため、コンポーネントごとにどちらかを主な仕組みとして選んでください。
主なプロパティとフック
ItemContent: ({ data, index, context }) => JSX— メッセージのレンダラーcontext—ItemContentとすべてのカスタムスロットで使用できる共有状態(現在のユーザー、読み込みフラグ)。プロパティのバケツリレーを避けられますcomputeItemKey({ data })— 安定したメッセージキー。再マウントを避けて prepend やストリーミングを機能させるために必要ですitemIdentity(item)— 制御された prepend/先頭からの削除、および更新によってアイテムオブジェクトが再生成される場合のアンカー付き置き換えの照合に使う、安定したアイテム識別子です- スロット:
Header、Footer(コンテンツと一緒にスクロール)、StickyHeader、StickyFooter(重なりを避けるために固定してサイズを測定)、EmptyPlaceholder initialLocation: { index: 'LAST', align: 'end' }— 最下部から開始します- フック(リスト内で使用):
useVirtuosoMethods()、useVirtuosoLocation()(isAtBottom、bottomOffset、listOffset、lastVisibleItemIndex、lastItemBottomOffset)、useCurrentlyRenderedData()。最後に表示されたアイテムに関するフィールドには 1.16.0 以降が必要です
注意点
- 高さがないと、何もレンダリングされません。 コンポーネントには実際の高さが必要です(サイズが指定された親要素内で
style={{ height: '100%' }}を指定します)。 - ライセンスラッパーがないと、実行時エラーが発生します。
VirtuosoMessageListLicenseでラップしてください。空のキーは、本番環境以外での評価に限り使用できます。 computeItemKeyがないと、prepend 時やストリーミング更新時に再マウントとスクロール位置のジャンプが発生します。itemIdentityがないと、制御された prepend/先頭からの削除の照合や、更新によってアイテムオブジェクトが再生成される場合のアンカー付き置き換えが正しく動作しないことがあります。- 測定対象のメッセージ行にマージンを指定すると、ResizeObserver がマージンを除外するため、高さの測定に支障が出ます。パディングまたは内側のラッパーを使用してください。
- 暗黙的なスクロールポリシーはチャットのデグレを招きます。 データの発生源ごとに方針を決めてください。通常、ローカルでの送信では最下部へ強制スクロールし、リモートからの受信では上へスクロール済みのユーザーの位置を維持し、ストリーミングには
items-changeを使い、コンテキストのみの行の伸長にはnotifyItemsChangedを使います。 - 「ResizeObserver loop」エラーは無害です — 開発用オーバーレイとエラートラッキングでは除外してください。詳しくは resize-observer-errors を参照してください。
- JSDOM のテストには、モックした測定値が必要です。
VirtuosoMessageListTestingContext.Provider value={{ itemHeight, viewportHeight }}と ResizeObserver のポリフィルを使用してください。スクロール動作のテストには Playwright を推奨します。testing を参照してください。
単純なマップ処理のチャットリストからの移行チェックリスト
VirtuosoMessageListLicenseラッパーを追加し、キーの受け渡し方法を決めます。- サイズを指定した親要素または直接のスタイルで、リストに適切な高さを設定します。
ItemContentを切り出し、共有状態をcontext経由で渡します。- メッセージ ID とローカルの楽観的 ID に対して、安定した
computeItemKeyを追加します。 - 制御対象のデータでオブジェクトを再作成し、先頭への追加やトリミングを行う場合は、
itemIdentityを追加します。 - 計測対象の行からマージンを取り除き、間隔にはパディングまたは内部ラッパーを使用します。
- ローカル送信、リモート受信、ストリーミング更新、ソケットエコー、外部での行の拡張に対して、明示的なスクロールポリシーを選択します。
- 可能な場合はスクロール動作のテストに Playwright/ブラウザテストを使用し、JSDOM のユニットテストにはテスト用コンテキストを使用します。
参考資料
- references/README.md — 概要とライブサンプル
references/2.tutorial/— チャットの構築をステップごとに解説:イントロダクション、メッセージリスト、過去のメッセージの読み込み、最下部へのスクロールボタン、メッセージの受信、メッセージの送信、複数のチャンネルreferences/3.examples/— メッセージング、AI チャットボット、Gemini、リアクション、日付区切り、返信へのスクロール、グループ化されたメッセージ- 機能ガイド:ヘッダーとフッター、コンテキスト、項目キー、フック、カスタムスクロールバー、項目へのスクロール、スムーズスクロール、命令型データ API、スクロール修飾子、制御モードのレシピ、テスト、ライセンス
API リファレンス全文:https://virtuoso.dev/message-list/