この記事で扱うこと
ブログのTILリストのように項目が増えていくページでは「全部を一度にレンダー」するとDOM・初期コストが大きくなる。同時に、初回画面はサーバーHTMLに実際のカードがないとSEO・LCPに不利だ。無限スクロールと仮想化を区別し、SSR 初回 slice → hydration 後 virtual list 切り替えパターンを一つの作業として整理する。
1. flatten → SSR 初回 slice → virtualizer 切り替え
背景知識(概念)
- 無限スクロール: スクロール末尾に到達したらデータをさらにロード(または露出上限を増やす)。
- 仮想化(windowing): 見える行だけをDOMに載せ、DOM数の上限を保つ。
- Hydration: サーバーHTMLに React がイベント・状態を付ける過程。
どんな状況だったか
ブログのTILリストが増えるにつれ「全部を一度にレンダー」方式のDOM・初期レンダーコストが大きくなった。年・月でグループ化されたネスト構造なので、virtualizer にそのまま入れるのも難しかった。
一方で初回訪問者には空の shell ではなく実際のカードがあるHTMLが必要だった。virtualizer だけを SSR すると、ビューポート外の行がHTMLから抜けうる。
目標は4つだった — サーバーHTMLに最初の10件の実際のカードを含める(初回 paint)、末尾に到達したら10件ずつ露出上限を増やす(無限スクロール)、見える範囲だけをDOMに保つ(DOM上限、TanStack Virtual)、ネストした年・月 → 1次元 row(month-header / til-item)(構造)。
主な作業
- flatten: 年・月のネスト構造を
month-header/til-itemの1次元 row に展開してから TanStack Virtual に接続した。 - SSR: サーバーはリストのメタだけをシリアライズし、
initialRows(最初の10件)と全体のrowsをクライアントに渡した。 - 静的 gate: hydration 前は静的なリストコンポーネントで初回 slice をHTMLに保証した。
- 切り替え:
useLayoutEffectの後に virtual list へスイッチした。 - 無限スクロール: スクロール末尾で
visibleCountを10ずつ増やす無限スクロールと組み合わせた。
教訓
- 無限スクロール ≠ 仮想化 — 前者はロード・露出するデータ量、後者はDOM行数を制限する。
- 可変高さのカードは
estimateSizeだけではスクロールジャンプ →measureElement補正が必要。 - virtualizer 単独の SSR は初回HTMLが不足しうるので、初回 paint が重要なら静的 gate パターンが良い。
- 後続: 項目が非常に多くなれば API ページングでクライアント全体配列の受け渡しを縮小し、e2e で
/tilの初回HTML・スクロールを検証する。
→ List virtualization and windowing · SSR hydration mismatch