React Virtuoso 仮想スクロール実装ガイド
React Virtuoso で大量データの仮想スクロールを組む実装ノート。基本の virtual list、グループ化 + sticky header の応用までを 1 ページに統合。
react-virtuoso で 100 万行を仮想スクロールする
react-virtuoso v4 で大量行リストを仮想スクロール化する最小コード、totalCount + itemContent パターン、よく詰まる点を触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
react-virtuoso@4.18.6対象: 数千〜数百万行のリストを React で表示する場面、ログビューア、無限スクロール
react-virtuoso v4 で 大量行リストを仮想スクロール(画面に映る行 + 上下 buffer 分だけ DOM に保持)化するパターン。totalCount + itemContent の最小コード、無限スクロール(endReached)、動的行高の自動測定を動く demo で確認します。
触って試す
行数を切替えて、100 万行でもスクロールが軽いことを確認できる。
なぜ仮想化が必要か
通常の <ul>{items.map(...)}</ul> は 全アイテムを DOM に存在させる。
| 行数 | 通常のレンダリング | 仮想スクロール |
|---|---|---|
| 1,000 | 余裕 | 余裕 |
| 10,000 | 重い(初期 500ms+) | 軽い |
| 100,000 | フリーズ | 軽い(可視範囲 + バッファのみ DOM) |
| 1,000,000 | クラッシュ | 軽い |
仮想スクロールは 画面に映る行 + 上下 buffer 分だけ DOM に保持する。
ポイントは DOM 数が「表示範囲 + 上下 buffer」だけで一定になること。totalCount を増やしても DOM 数は変わらず、スクロール時のメモリ・layout コストが scale-out しない。
最小サンプル
<Virtuoso> に totalCount と itemContent(index) を渡すだけ。スクロールは Virtuoso 自身が管理:
import { Virtuoso } from "react-virtuoso";
export function List({ rows }: { rows: { id: number; text: string }[] }) {
return (
<Virtuoso
style={{ height: 400 }}
totalCount={rows.length}
itemContent={(index) => <div>{rows[index].text}</div>}
/>
);
}
API が小さい:totalCount で総数、itemContent(index) で 1 行 JSX。
高さの扱い
Virtuoso は 行の高さがバラバラでも自動計算 する(動的高さに対応)。固定高でない場合も特に props を足す必要なし。
ただしパフォーマンス重視なら defaultItemHeight で初期推定値を渡すと、初回レイアウトのジャンプが減る。
無限スクロール(API ページネーション)
endReached callback で末尾到達を検知 → API から次ページを fetch → state に append するパターン:
const [rows, setRows] = useState<Row[]>([]);
<Virtuoso
style={{ height: 400 }}
data={rows}
endReached={async () => {
const next = await fetchNextPage(rows.length);
setRows((prev) => [...prev, ...next]);
}}
itemContent={(index, row) => <div>{row.text}</div>}
/>
endReached で末尾に到達した時に追加 fetch。バッファ範囲も自動で調整される。
ポイント:
endReachedの発火タイミングは「末尾の endReached 内側 buffer に到達したとき」(デフォルト最後の行に到達直前)。fetch latency の余裕を作るためにoverscanを増やすと早めに firing する- 重複 fetch を防ぐ:fetch 中フラグを置いて、
endReachedの内側でif (loading) returnする
分類別コンポーネント
| Component | 用途 |
|---|---|
Virtuoso | 縦リスト(基本) |
TableVirtuoso | <table> 構造の仮想化 |
GridVirtuoso | グリッド表示 |
VirtuosoMessageList | チャット系(末尾追従、過去ロード) |
GroupedVirtuoso | sticky group header 付き — /articles/react-virtuoso-grouped-sticky/ で深掘り |
つまずいたポイント
- 親要素の高さが必須:
style={{ height: ... }}を渡さないと 0 で何も表示されない。flex 親ならflex: 1 - 同じデータで再描画:
dataの参照が変わらないと追加されたと認識されないので、setRows([...prev, ...next])のように新配列を作る - server-render しない:Astro / Next.js では
client:loadで hydrate(SSR の出力は空コンテナ + 後から hydrate) - 横スクロールが必要なケース:
itemContent内でoverflow-x: autoを当てる。Virtuoso 自身は縦のみ - scroll position の維持:タブ切替や戻る操作での scroll 位置は、
ref={virtuosoRef}+scrollToIndex(index)で復元
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 学習コスト | ◎ | totalCount + itemContent の 2 つで開始可能 |
| 動的行高 | ◎ | 計測自動、設定不要 |
| パフォーマンス | ◎ | 100 万行でも 60fps |
| 無限スクロール | ◎ | endReached で組み込み対応 |
| バンドル | ○ | 30KB 前後(min+gzip) |
向く / 向かないケース
- 向く: ログビューア、SNS タイムライン、大量検索結果、数千行 admin テーブル
- 向かない: 数十行のリスト(オーバーキル)、複雑な grid / sticky header(Grouped/Table 版を選ぶ)
- 向かない: print したい場合(仮想化されているので print 時に全行は出ない)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
react-virtuoso で group + sticky header + scrollToIndex
react-virtuoso の踏み込んだ機能 — GroupedVirtuoso で sticky group header、groupCounts による平坦化と group 復元、scrollToIndex / scrollIntoView で外部から位置制御するパターンを触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
react-virtuoso@4.18.6対象: 入門は通っている、グループ化された大量データ(チーム別ユーザー一覧 / 月別取引履歴 等)を仮想スクロールで扱いたい人
react-virtuoso の踏み込んだ機能 — GroupedVirtuoso で sticky group header、groupCounts による平坦化と group 復元、scrollToIndex / scrollIntoView で外部から位置制御するパターンを動く demo で確認します。
触って試す
上のボタンで該当チームへジャンプ + sticky header(チーム名)が画面上部に貼り付く。
1. groupCounts で構造を渡す
GroupedVirtuoso は group ごとの行数配列(groupCounts) で構造を表現する:
import { GroupedVirtuoso } from "react-virtuoso";
const groups = [
{ team: "Frontend", rows: [...] }, // 50 行
{ team: "Backend", rows: [...] }, // 80 行
{ team: "DevOps", rows: [...] }, // 30 行
];
const groupCounts = groups.map((g) => g.rows.length); // [50, 80, 30]
const flatRows = groups.flatMap((g) => g.rows); // 平坦な 160 行配列
<GroupedVirtuoso
style={{ height: 360 }}
groupCounts={groupCounts}
groupContent={(groupIndex) => (
<div className="px-3 py-2 font-semibold sticky-header">
{groups[groupIndex].team}
</div>
)}
itemContent={(itemIndex) => (
<div>{flatRows[itemIndex].name}</div>
)}
/>
ポイント:
groupCounts: number[]で group の構造を表現(各要素 = その group の行数)groupContent(groupIndex)= group header(自動で sticky になる)itemContent(itemIndex)= 平坦化した行 index で各行を render- flat ↔ grouped の変換 は呼び出し側で(virtuoso 側が裁定はしない)
2. scrollToIndex で外部から位置制御
Virtuoso の ref 経由で命令的に scroll:
import { useRef } from "react";
import { GroupedVirtuoso, type VirtuosoHandle } from "react-virtuoso";
const ref = useRef<VirtuosoHandle>(null);
// 200 行目に飛ぶ
ref.current?.scrollToIndex({ index: 200, align: "start" });
align:
"start"(画面上端に index を合わせる)"center"(中央)"end"(下端)
実用ヒント:
- 「次のグループ先頭」UI →
align: "start"(header が上端に sticky) - 「メッセージ詳細にフォーカス」 →
align: "center"(target を中央に) - 「最新まで一気にスクロール」 →
align: "end"+behavior: "smooth"
behavior: "smooth" を渡せばスムーススクロール:
ref.current?.scrollToIndex({ index: 500, align: "center", behavior: "smooth" });
3. group 開始 index を計算してジャンプ
「Backend グループの先頭へ」のような UI には、group ごとの先頭 flat index を計算しておく:
const groupStartIndices = useMemo(() => {
const out: number[] = [];
let acc = 0;
for (const g of groups) {
out.push(acc);
acc += g.rows.length;
}
return out;
}, [groups]);
// Backend(index=1) の先頭へ
ref.current?.scrollToIndex({ index: groupStartIndices[1], align: "start" });
4. 動的に行が増減する場面(遅延更新)
サーバから随時 fetch してくる場合は firstItemIndex を一緒に渡すと、頭追加でも scroll 位置がずれない:
<Virtuoso
firstItemIndex={firstItemIndex} // 上に load した時に減らす
initialTopMostItemIndex={500} // 起動時の初期位置
data={rows}
itemContent={(index, row) => <Row row={row} />}
/>
「過去ログを上に load」のチャット系 UI で必須。
5. 動的行高(明示しなくても自動測定)
Virtuoso は 行高を測定しながら描画する。固定でも可変でも OK。
ただし 画像が後から load される行 は初回測定が短すぎることがあるので、onLoad で forceUpdate を呼ぶか、画像に aspect-ratio CSS を当てて事前に高さを確保。
<img
src={src}
loading="lazy"
style={{ aspectRatio: "16 / 9", width: "100%" }}
/>
6. Group header のカスタマイズ
groupContent の中で z-index / shadow / 色を CSS で:
groupContent={(groupIndex) => (
<div
className="px-3 py-2 text-xs font-semibold"
style={{
background: "var(--color-paper)",
borderBottom: "1px solid var(--color-line)",
borderTop: "1px solid var(--color-line)",
}}
>
{groups[groupIndex].team}
<span className="ml-2 font-mono">({groups[groupIndex].rows.length})</span>
</div>
)}
GroupedVirtuoso は header に 自動で sticky 効果を当てる(自前で position: sticky 不要)。
7. 関連 Component の使い分け
| Component | 用途 |
|---|---|
Virtuoso | 縦リスト基本(group なし) |
GroupedVirtuoso | グループ化 + sticky header |
TableVirtuoso | <table> 構造の仮想化(thead sticky) |
GridVirtuoso | グリッド表示 |
VirtuosoMessageList | チャット系(末尾追従、過去ロード) |
「sticky header + 大量行 + 動的行高 + 末尾追従」が必要なら virtuoso 系がほぼ唯一の現実解。
つまずいたポイント
groupCountsが空配列:GroupedVirtuoso が何も描画しない。groupCounts.length === 0 ? <Empty /> : <GroupedVirtuoso ... />で出し分けgroupContentの高さが毎回違う:測定タイミングが揺れて scroll が跳ねる。固定 height にするか line-height 揃えるscrollToIndex直後に再描画:行の高さが未測定だと正確な位置に飛ばない、scrollToIndexをsetTimeout(0)で 1 tick 遅延- server-render しない:Astro/Next.js は
client:loadで hydrate - horizontal scroll が必要:Virtuoso は縦のみ、
itemContent内でoverflow-x: autoする div を作る
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
次に読むガイド
- 2 セクション統合
Framer Motion v12 アニメーション実装ガイド
Framer Motion v12 で UI アニメーションを組むパターン。基本の transition recipe、layoutId による magic move までを 1 ページに統合。
- 2 セクション統合
dnd-kit v6 ドラッグ & ドロップ実装ガイド
dnd-kit v6 でドラッグ&ドロップ UI を組む実装パターン。sortable list の最小実装、複数カラムを跨ぐ Kanban(列移動の状態管理を含む)、useSensor / DragOverlay の細部までを 1 ページに統合した実装ノート。