1万行のリストが重い。仮想スクロールで“見える分だけ”描く実装手順
大量リストが重い理由と、見えている範囲だけ描く仮想スクロールの作り方。react-window・TanStack Virtual・virtuaの選び方、固定/可変高さ、コピペ実装まで。
ログビューアに1万行を流し込んだら、スクロールがカクカクして指が引っかかるようになりました。
データ取得は一瞬で終わっている。APIも速い。なのに重い。Chromeの開発者ツールでDOMを数えたら、<div> が1万個ぶら下がっていました。画面に映っているのは、せいぜい12行。残りの9988行は、誰も見ていないのにブラウザがレイアウトを計算し続けていたわけです。
このとき僕がやったのが仮想スクロール(リスト仮想化)でした。発想は身もふたもなくシンプルで、「見えている分だけ描く。残りは描かない」。これだけで、1万行のDOMが画面の周辺20行くらいまで減ります。
この記事は、その仕組みと、react-window・TanStack Virtual・virtuaという3つの定番ライブラリの選び方、そしてコピペで動く実装までを順番に説明します。データを少しずつ取ってくる「無限スクロール」は別物なので、そこは 無限スクロール実装 に送り分けます。
この記事の要点
- 大量行が重いのは、見えない行までブラウザがレイアウト・描画・イベント管理を続けるから。仮想スクロールは画面の周辺だけ描いて残りを省く。
- 仕組みは3点セット。外側のスクロール枠/全件分の高さを持つ「ダミーの背の高い箱」/その中に絶対配置した数十行だけ。スクロール位置から「今どの番号を描くか」を毎フレーム計算する。
- ライブラリは用途で選ぶ。固定高さで軽く済むなら react-window、高さがバラつく・凝った制御をするなら headless の TanStack Virtual、小さく入れたいなら virtua。
- 固定高さは簡単、可変高さは難所。実DOMを測って高さを補正する仕組み(
measureElement)が要る。画像の遅延ロードでスクロールが跳ねるのが定番の罠。 - データを順次取得する無限スクロールとは設計が別。描画を減らすのが仮想スクロール、取得を分けるのが無限スクロール。両者は組み合わせられる。
そもそも、なぜ大量行はそんなに重いのか
array.map() で全行を返すと、ブラウザはその全部を本物のDOM要素として作ります。ここで問題なのは、DOMは「作って終わり」じゃないことです。
ブラウザは要素1個ごとに、こんな仕事をずっと抱えます。
- レイアウト計算: 各行が画面のどこに来るか、幅と高さはいくつか
- ペイント/合成: 実際にピクセルを塗る
- アクセシビリティツリー: 支援技術に渡す構造を作る
- イベント管理: クリックやホバーの当たり判定を保持する
1万行あれば、これが1万回分です。スクロールするたびに再計算が走り、メモリも食う。画面に映っていない行のために、こんな重労働を続けている——それが「データは速いのにUIが重い」の正体です。
仮想スクロールは、ここを断ち切ります。見えている12行+上下の予備を合わせても、DOMはせいぜい20〜30個。9000個以上の重労働がまるごと消えるので、軽くなって当然なんですね。
仕組みを3つの箱で理解する
中身は意外と素朴です。HTMLの構造を3つの箱で考えると一発で腑に落ちます。
- 外側のスクロール枠: 高さを固定し、
overflow: autoをかけた箱。ここがスクロールバーを持つ。 - 背の高いダミー箱: 全件ぶんの高さ(1行44px × 1万行 = 44万px)を持つ、中身カラの箱。これがあるからスクロールバーは「1万行ぶん」の長さになる。
- 見える行だけ: ダミー箱の中に
position: absoluteで置いた、今映っている数十行。
ポイントは、3つめの行たちを transform: translateY(...) で「本来あるべき位置」へずらして置くこと。スクロール位置(scrollTop)から「今は何番目〜何番目を描けばいいか」を計算し、その範囲だけ作り直す。
scrollTop(今のスクロール量)
→ 見えている番号の範囲を計算(例: 230〜242行目)
→ 予備(overscan)を上下に少し足す(例: 220〜252)
→ その行だけDOMに描く
→ translateYで本来の位置へ配置
→ 可変高さなら実DOMを測って次回の計算を補正
overscan(予備描画)は、画面外に少しだけ余分に描いておく行数です。これが少なすぎると、速くスクロールしたとき描画が追いつかず白い隙間がチラつく。多すぎると、せっかく減らしたDOMがまた増えて意味が薄れる。軽い行なら8〜16くらいから試すのがちょうどいいです。
ライブラリの選び方:react-window / TanStack Virtual / virtua
自力で実装してもいいですが、スクロール中の補正や可変高さの再計測まで考えると、まず破綻します。素直に定番へ寄せましょう。2026年6月時点で現実的な選択肢は次の3つです。
| ライブラリ | 性格 | 向いている場面 | 注意点 |
|---|---|---|---|
| react-window | 古株で枯れている。軽量で学習が速い | 固定高さの長い表・リストをサッと軽くしたい | 可変高さは VariableSizeList で一応対応だが、凝った制御は手薄。開発の動きは穏やか |
| TanStack Virtual | headless(見た目を持たない)。計算だけ担う | 高さがバラつく、固定ヘッダーや選択状態など細かく作り込む | マークアップ・CSSは全部自分で書く。自由なぶん書く量は増える |
| virtua | 軽量で導入が小さい。今どきの実装 | 小さく入れたい、設定を増やしたくない | 細かい挙動は自分の要件で要検証 |
ざっくりの指針はこうです。固定高さで十分なら react-window。一覧の行高さがコンテンツ次第で変わる、あるいは固定ヘッダーやキーボード操作まで作り込むなら TanStack Virtual。とにかく軽く小さく入れたいなら virtua を試す。
「headless」という言葉が分かりにくいので補足します。headlessとは、見た目(マークアップとCSS)を一切持たず、計算ロジックだけを提供する設計のこと。TanStack Virtual は「今どの行を、どの位置に描くべきか」だけ教えてくれて、<div> を実際に並べるのは自分の仕事になります。面倒に見えますが、自分のデザインシステムに完全に馴染ませられるのが強みです。
ちなみに同じ作者のテーブル設計(ソート・ページネーション・仮想化)を詰める話は Reactのテーブル実装 にまとめてあります。表で使うなら合わせてどうぞ。
まず動かす:react-windowで固定高さリスト
いちばん理解が速いのは固定高さです。react-window で1万行のログを軽くしてみます。全行を map する版と差し替えるだけで、DOMの数が劇的に減ります。
まず入れます。
npm install react-window
そして本体。itemSize に1行の高さ(px)を、itemCount に総件数を渡すだけ。描画する中身は Row に書きます。
import { FixedSizeList, type ListChildComponentProps } from "react-window";
type LogRow = {
id: string;
level: "info" | "warn" | "error";
message: string;
createdAt: string;
};
// 1行ぶんの見た目。style は react-window が位置を計算して渡してくる。
// この style を必ず付けないと、行が全部 0,0 に重なって表示が壊れる。
function Row({ index, style, data }: ListChildComponentProps<LogRow[]>) {
const row = data[index];
return (
<div
style={{
...style,
display: "grid",
gridTemplateColumns: "160px 64px minmax(0, 1fr)",
gap: 12,
alignItems: "center",
padding: "0 12px",
boxSizing: "border-box",
borderBottom: "1px solid #eee",
}}
>
<time dateTime={row.createdAt}>{row.createdAt}</time>
<strong>{row.level.toUpperCase()}</strong>
{/* 長い文字列でも横にはみ出さないよう anywhere で折り返す */}
<span style={{ overflowWrap: "anywhere" }}>{row.message}</span>
</div>
);
}
export function VirtualLogViewer({ rows }: { rows: LogRow[] }) {
return (
<FixedSizeList
height={520} // スクロール枠の高さ
width="100%"
itemCount={rows.length} // 総件数(ここでは1万でもOK)
itemSize={44} // 1行の高さ。固定なのでこれだけで足りる
itemData={rows} // Row に渡す配列
overscanCount={8} // 画面外に少し余分に描く保険
>
{Row}
</FixedSizeList>
);
}
ここで唯一にして最大の注意点は、Row に渡ってくる style を必ず付けること。react-window はこの style の中で各行の position と top(translateY相当)を渡してきます。これを付け忘れると、全行が左上に重なって「1行しか表示されない」という見た目になります。僕が最初にハマったのもこれでした。
itemData に配列を渡しているのは、Row 側で data[index] として受け取るためです。クロージャで外の変数を直接掴むより、こうして渡したほうが再描画の挙動が素直になります。
可変高さの罠:チャット履歴やコメント欄
固定高さは簡単でした。難所は、行の高さがバラバラなときです。チャット履歴、コメント欄、検索結果のカード。本文の長さ、画像の有無、添付ファイルで高さが変わります。
固定高さの前提で組むと、こうなります。
- 想定44pxで計算したのに、実際は120pxの行があり、下の行が重なる
- スクロールバーの長さが実際とズレて、いちばん下までスクロールできない/余る
- 画像が遅れて読み込まれて高さが伸び、スクロール位置が突然ジャンプする
可変高さでは「とりあえずの高さ(estimate)で先に計算 → 実際にDOMを描いたら本当の高さを測って補正」という二段構えが必要です。これを自前の配列で管理するのは、スクロール中の補正まで考えると地獄なので、ここは headless の TanStack Virtual に寄せるのが現実的です。
npm install @tanstack/react-virtual
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type Message = {
id: string;
author: string;
body: string;
avatarUrl?: string;
};
export function VirtualChatHistory({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 96, // とりあえずの高さ。実測でこの後ズレを直す
overscan: 8,
getItemKey: (index) => messages[index]?.id ?? index,
});
return (
<div
ref={parentRef}
role="log"
aria-label="チャット履歴"
style={{ height: 520, overflow: "auto" }} // 外側のスクロール枠
>
{/* 全件ぶんの高さを持つダミー箱 */}
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => {
const message = messages[item.index];
if (!message) return null;
return (
<article
key={item.key}
data-index={item.index}
// ↓ これが「実DOMを測って高さを補正する」仕掛け
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${item.start}px)`,
padding: "12px 16px",
boxSizing: "border-box",
}}
>
{message.avatarUrl ? (
<img
src={message.avatarUrl}
alt=""
width={32}
height={32}
loading="lazy"
// 画像が遅れて読み込まれたら測り直し、ジャンプを防ぐ
onLoad={() => virtualizer.measure()}
/>
) : null}
<p style={{ margin: 0, fontWeight: 700 }}>{message.author}</p>
<p style={{ margin: "4px 0 0", overflowWrap: "anywhere" }}>
{message.body}
</p>
</article>
);
})}
</div>
</div>
);
}
肝は2か所です。ref={virtualizer.measureElement} を各行に付けると、ライブラリが実際の高さを測って次の計算に反映してくれます。そして画像。遅延ロードした画像が表示された瞬間に高さが変わるので、onLoad で virtualizer.measure() を呼んで測り直す。これを入れないと、スクロール中に画像が読み込まれるたびに位置が跳ねます。
可変高さで画像を扱うなら、画像に width/height を先に持たせる、プレースホルダーを置く、という対策も併用すると跳ねがさらに減ります。
無限スクロールやスクロール位置復元と組み合わせる
仮想スクロールとよく混同されるのが無限スクロールです。役割がまったく違います。
- 仮想スクロール: 手元にある大量データの描画を減らす。データは全部持っている前提。
- 無限スクロール: スクロールが下端に近づいたら次のデータを取りに行く。描画の話ではなく取得の話。
つまり「サーバーから少しずつ取ってくる」のは無限スクロールの守備範囲です。両者は敵対しません。むしろ「無限スクロールで取得 → 取得済みの大量データを仮想スクロールで軽く描く」という合わせ技が王道です。取得側の作り方は 無限スクロール実装 に分けて書いたので、組み合わせたい人はそちらへ。描画全体のボトルネックの見つけ方は パフォーマンス最適化 が地続きです。
もう一つ、実務で必ず効くのがスクロール位置の復元です。一覧から詳細へ飛んで戻ったとき、先頭に戻ると作業位置を見失います。scrollTop を保存して復元しましょう。ただし検索条件やソートが変わったら復元してはいけません(別の行に飛んでしまう)。条件をキーに含めるのがコツです。
import { useEffect } from "react";
type RestoreOptions = {
storageKey: string; // 例: `logs:${query}:${sort}` のように条件込みにする
getScrollElement: () => HTMLElement | null;
};
// スクロール位置を sessionStorage に保存・復元する小さなフック
export function useScrollRestoration({ storageKey, getScrollElement }: RestoreOptions) {
// 復元:保存済みの scrollTop を戻す
useEffect(() => {
const saved = sessionStorage.getItem(storageKey);
const el = getScrollElement();
if (!saved || !el) return;
// レイアウト確定後に戻すため1フレーム待つ
requestAnimationFrame(() => {
el.scrollTop = Number(saved);
});
}, [storageKey, getScrollElement]);
// 保存:スクロールのたびに現在位置を控える
useEffect(() => {
const el = getScrollElement();
if (!el) return;
const save = () => sessionStorage.setItem(storageKey, String(el.scrollTop));
el.addEventListener("scroll", save, { passive: true });
return () => {
save();
el.removeEventListener("scroll", save);
};
}, [storageKey, getScrollElement]);
}
storageKey に検索語やソート順を混ぜておけば、「同じ条件なら位置を復元、条件が変われば先頭から」を自動で切り替えられます。
僕がやらかした失敗と落とし穴
正直に書きます。仮想スクロールは「軽くなった気がする」で公開すると、別の形で事故ります。
ひとつ目は、さっきの style 付け忘れ。react-window の行が全部左上に重なって「1行しか出ない」と30分悩みました。位置を計算する style を行に渡すのを忘れていただけでした。
ふたつ目は、可変高さを固定として扱ったこと。チャットを estimateSize: 96 のまま measureElement を付けずに出したら、長文メッセージの下の行が重なり、いちばん下までスクロールできませんでした。実測の補正は省けません。
みっつ目は、画像ロードのジャンプ。アバター画像が遅れて表示されるたびにスクロール位置が跳ね、読んでいた箇所を見失う。onLoad で測り直すまで気づきませんでした。
よく出る落とし穴を表にまとめます。
| 落とし穴 | 何が起きるか | 対策 |
|---|---|---|
行の style(位置指定)を渡し忘れる | 全行が重なり1行しか見えない | react-window は受け取った style を必ず付ける |
| 可変高さを固定として扱う | 下の行が重なる/最下部まで届かない | measureElement で実測し補正する |
| 画像の遅延ロード | スクロール位置が跳ねる | onLoad で測り直す、画像に幅・高さを先に持たせる |
| overscan不足 | 高速スクロールで白い隙間がチラつく | 8〜16あたりで調整する |
| overscan過多 | 結局DOMが増えて重い | DOM件数とProfilerで確認する |
| 長い文字列 | モバイルで横幅が崩れる | minmax(0, 1fr) と overflowWrap: "anywhere" |
| 件数を支援技術に伝えない | 読み上げで総数や現在位置が分からない | aria-posinset/aria-setsize を付ける(アクセシビリティ実装) |
横幅崩れは特に見落とされます。行を position: absolute にすると、長い文字列ひとつでモバイルの横スクロールが出ます。overflowWrap: "anywhere" と minmax(0, 1fr) をセットで入れておくと安全です。
よくある質問
Q. react-window と TanStack Virtual、結局どっちを使えばいい? A. 行の高さが全部同じなら react-window でほぼ十分です。設定が少なく学習も速い。高さがコンテンツでバラつく、固定ヘッダーや選択状態まで作り込む、といった要件があるなら headless の TanStack Virtual。見た目を自分で書く手間と引き換えに、細かい制御が効きます。
Q. 何行くらいから仮想スクロールを入れるべき?
A. 目安は数百行を超えてスクロールが体感で重くなり始めたあたり。逆に総件数が数十件なら、普通の map で十分です。仮想化はコードが増えるぶん、効果が出ない規模で入れると保守の負担だけ増えます。
Q. 仮想スクロールと無限スクロールは何が違う? A. 仮想スクロールは「手元の大量データの描画を減らす」、無限スクロールは「スクロールに応じて次のデータを取りに行く」。描画の最適化と取得の分割で、層が違います。両方を組み合わせるのが普通です。取得側は 無限スクロール実装 を参照してください。
Q. SSR(Next.jsやAstro)だと高さがガタつくのはなぜ?
A. サーバー側ではブラウザの実サイズを測れないため、初回HTMLとクライアント描画でズレが出て、hydration後に高さが変わるからです。対策は、スクロール部分をクライアント専用コンポーネントにする、コンテナ高さをCSSで固定する、estimateSize を実測に近づける、画像サイズを先に確保する、の順で詰めます。
Q. 仮想化するとCtrl+Fのページ内検索が効かなくなる? A. はい。描画していない行はDOMに存在しないので、ブラウザ標準の検索には引っかかりません。記事一覧のように全文検索させたい画面では、仮想化より素直なリストやページネーションのほうが向きます。アプリ内に専用の検索フィルタを用意するのが現実的な落としどころです。
実際に試した結果
冒頭の1万行ログビューアは、array.map() 版だとDOMが1万個でスクロールが詰まっていました。react-window に差し替えたら、DOMは画面周辺の20〜30個まで減り、スクロールの引っかかりが消えました。差し替え自体は30分、ハマったのは style の付け忘れの一点だけです。
可変高さのチャットは話が別で、measureElement を付けるまで長文行が重なり、画像の onLoad で測り直すまでスクロールが跳ねていました。最終的に効いたのは、(1) 実測補正を必ず入れる、(2) 画像に幅・高さを先に持たせて onLoad で測り直す、(3) storageKey に検索条件を混ぜて位置復元を条件ごとに切り替える、の3点。この3つを公開前チェックに固定してから、リスト周りの「なんか重い・なんか飛ぶ」報告がほぼ来なくなりました。
仕組みは一度つかめば単純です。見えている分だけ描く。まずは固定高さの react-window で1か所、いちばん重いリストを差し替えてみてください。可変高さや無限スクロールとの合わせ技は、そのあとで足せば間に合います。手を動かす題材が欲しいときは 教材一覧 と公式ドキュメント(TanStack Virtual docs)から始めるのがおすすめです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。