定義
数千件の長い一覧をスクロールするときにブラウザが遅くなるのを防ぎたいときに使う手法である。データが数千件あっても、実際に画面に描くDOM(ブラウザが扱う文書要素のツリー)の行は今目に見える少数だけを維持することが要点である。
正確に言えば、リスト仮想化(windowing)は、数百〜数千件の項目がある一覧で、現在のビューポート(ブラウザ画面で実際に見える領域)(またはスクロールコンテナ)に見える行だけをDOMにレンダーし、スクロール位置に応じて行を再配置・入れ替えて、メモリ使用量とレイアウト・ペイントコストを一定に保つUI手法である。無限スクロールが「どれだけのデータを読み込むか」を制御するのに対し、仮想化は「読み込まれたデータのうち実際にDOMに載せる行」を制限する。
なぜ必要か
すべての項目を一度にDOMに置くと、初回ペイント・スクロール・リフロー(reflow、要素の位置・サイズを再計算するブラウザの作業)コストが項目数に比例して大きくなる。無限スクロールだけを適用すると、ユーザーがスクロールするほど読み込まれた項目数は増えるが、結局DOMノード数も一緒に増え、長時間スクロール後にはブラウザが遅くなることがある。仮想化はデータ配列は長くてもDOMはビューポート付近の少数の行だけを維持する。critical-rendering-pathの観点でlayout/paint対象のノード数を制限する効果がある。
動作原理
- Flatten row model: 年・月グループのようなnested UIはvirtualizer入力に合わないことが多い。
month-header|itemのような1次元のrow配列に展開した上でcountとgetItemKeyで管理する。 - Window virtualizer: ページ全体がスクロールコンテナなら
useWindowVirtualizerを使い、固定高さパネル内ならuseVirtualizerを使う。上部固定ヘッダー・フィルタがあればscrollMarginでoffsetを合わせる。 - 可変高さ:
estimateSizeで初期高さを推定し、レンダー後measureElementで実際の高さを補正する。推定が外れるとスクロールジャンプが発生する。 - 無限スクロール結合: スクロール終端で
visibleCount(またはpage)を増やし、virtualizerのcountも一緒に更新する。データ露出とDOM上限を分離して制御する。 - SSR + virtual: SSR(Server-Side Rendering、サーバーがHTMLをあらかじめ作って送る方式)/初期レンダーでvirtualizerだけを使うと、ビューポート外の行がHTMLから抜けることがある。初回paint・SEO・hydration(サーバーが作ったHTMLにReactがイベントを付けて実際に動かす過程)が重要なら、静的な最初のページをSSRで保証した上で、
useLayoutEffect以降にvirtual listへ切り替える(ssr-hydration-mismatch)。
実務での適用
- サーバーは一覧に必要なメタだけをシリアライズし本文は除外する(single-source-of-truth-content-metadata)。クライアントは
visibleCountで漸進的に露出する。 - slice時に月ヘッダーが切れないよう、visibleなTIL slug集合に該当する
month-headerだけを残すロジックを置く。 - タグフィルタなど条件が変わったら
<VirtualList key={filter}>で内部状態をリセットする。 - 項目が数万件以上なら、クライアント全体配列の代わりにAPIページング(
/api/items?offset=&limit=)を検討する。
トレードオフ
| 選択 | 長所 | 短所 |
|---|---|---|
| 仮想化なし | 実装が単純、SEO・アクセシビリティの予測が容易 | 項目数増加時に性能低下 |
| virtualizerのみSSR | コードが単純 | 初回HTML・hydrationで項目欠落の可能性 |
| 静的gate + virtual | 初回paint・hydrationが安定 | 2つのレンダー経路の維持コスト |
| ライブラリ(例: TanStack Virtual) | 可変高さ・window scroll対応 | 学習・バンドルコスト |
使ってはいけない場合
- 項目が数十個以下でDOMコストが無視できる場合。
- 一覧全体がSEO・印刷・スクリーンリーダーで一度に読まれる必要がある場合(仮想化はDOMにない行をスキップする)。
- 行高さが極端に不規則で、測定補正なしに推定だけを使う場合(スクロールジャンプ)。
よくある間違い
- nestedな
<section>構造をvirtualizerに直接載せてslice・キー・測定が複雑になる → flatten rowに切り替える。 - 無限スクロールだけを使い仮想化を省略して、読み込まれた項目が増えるとDOMが増え続ける。
- virtualizerだけをSSRして初回画面のHTMLが空になったりhydration不一致が起きる。
estimateSizeだけを使いmeasureElementを省略して可変高さのカードでスクロールが跳ねる。
関連概念
- ssr-hydration-mismatch — クライアント専用UI(virtual切り替え)をマウント・layout effect以降に遅らせる原則。
- critical-rendering-path — DOM規模の縮小が初期・スクロールレンダーコストに与える影響。
- single-source-of-truth-content-metadata — 一覧メタだけをサーバーでシリアライズしてRSC(React Server Components、サーバーでのみ実行されるReactコンポーネント)境界のペイロードを減らすパターン。