【完全版】React仮想スクロール徹底解説:概念からRemixでの実装まで
Webアプリケーションで数千、数万件のリストを表示する際、パフォーマンスの低下は避けて通れない問題です。この問題を解決する強力な技術が「仮想スクロール」です。
この記事では、仮想スクロールの基本的な概念から、React(Remix)での具体的な実装方法、そしてスマートフォン対応まで、ステップバイステップで徹底的に解説します。
- 仮想スクロールとは?
仮想スクロールとは、一言でいうと**「ユーザーの画面に見えている部分のアイテムだけを描画(レンダリング)する技術」**です。
なぜ必要?
通常のリスト表示では、10,000件のデータがあれば10,000個のHTML要素をすべて生成しようとします。これにより、以下のような問題が発生します。
表示速度の低下: ページの初期読み込みが非常に遅くなります。
パフォーマンスの悪化: スクロールがカクカクし、ユーザー体験を損ないます。
メモリの大量消費: ブラウザが大量のメモリを消費し、最悪の場合フリーズします。
仮想スクロールの解決策
仮想スクロールは、この問題を根本から解決します。
通常のスクロール
仮想スクロール
全10,000個のアイテムを全部描画する。
見えている範囲のアイテム(例:15個)だけを描画する。
遅い・重い
速い・軽い
ユーザーがスクロールすると、見えなくなったアイテムをDOMから削除し、新しく見える範囲に入ったアイテムをDOMに追加します。この処理を高速で行うことで、あたかも全アイテムがそこにあるかのような、滑らかなスクロール体験を実現します。
- 仕組み:2つのコンテナを使った「見せかけ」のトリック
仮想スクロールは、巧妙なトリックで成り立っています。主役は2つの「コンテナ(div要素)」です。
① スクロールバー担当:「窓」コンテナ
まず、実際にユーザーの画面に見える、高さが固定されたコンテナがあります。これを「窓」と呼びましょう。
heightが固定(例: 80vh)。
overflow: 'auto'が設定されており、スクロールバーを持つ責任者です。
ライブラリは、この「窓」のスクロール位置を常に監視します。
② 全体の高さ担当:「巨大」コンテナ
次に、「窓」コンテナの内側に、もう一つのコンテナを置きます。これを「巨大コンテナ」と呼びましょう。
このコンテナのheightは、**「もし全アイテムを描画したらどのくらいの高さになるか」**という計算上の巨大な値(例: 61px * 10000件 = 610000px)に設定されます。
このコンテナの役割はただ一つ。親である「窓」コンテナに、正しい長さのスクロールバーを表示させることです。
position: 'relative'が設定され、後述する各アイテムの配置の基準点となります。
③ アイテムの配置:transformによる瞬間移動
実際に描画される少数のアイテム(例: 15個)は、この「巨大コンテナ」の中に配置されます。
各アイテムはposition: 'absolute'を持ちます。これにより、アイテム同士が重なり合うことができ、自由に配置可能になります。
そして、transform: translateY(〇〇px)を使い、「巨大コンテナ」内の本来あるべき位置まで瞬間移動させます。
例えば、500番目のアイテム(1つの高さ61px)なら、61px * 499の位置にtranslateYで配置されます。
ユーザーが「窓」をスクロールすると、ライブラリがそのスクロール位置を検知し、表示すべきアイテムの範囲とそれぞれのtranslateYの値を再計算して、DOMを高速で入れ替えます。
- 実装:TanStack Virtualを使ってみよう
この複雑な計算とDOM操作を自力で行うのは大変なので、@tanstack/react-virtualのようなライブラリを使うのが一般的です。
useVirtualizerフックの役割
このライブラリの中核となるのがuseVirtualizerフックです。このフックにいくつかの情報を教えるだけで、仮想スクロールに必要な計算をすべて行ってくれます。
フックに教えること(オプション):
count: アイテムの総数。
getScrollElement: スクロールを監視する「窓」コンテナがどれかを教えます。(useRefを使います)
estimateSize: アイテム1つのおおよその高さを教えます。
フックから教えてもらうこと(戻り値):
getTotalSize(): 「巨大コンテナ」が持つべき高さを返します。
getVirtualItems(): 今、画面に表示すべきアイテムの情報(indexや配置位置startなど)が入った配列を返します。
この戻り値を使って、JSXを組み立てるだけで仮想スクロールが完成します。
- SP対応:可変の画面サイズにどう対応するか?
スマートフォンの画面サイズは機種ごとに異なります。height: '600px'のように固定値を指定すると、レイアウトが崩れてしまいます。
解決策は、CSSのvh(Viewport Height)単位とcalc()関数を組み合わせることです。100vhは画面全体の高さを表します。
例えば、アプリの上部にヘッダー(60px)、下部にナビゲーションバー(80px)がある場合、リストが表示される「窓」の高さは calc(100vh - 60px - 80px) となります。これにより、どんなデバイスでも利用可能な領域いっぱいにリストを表示できます。
次のセクションでは、これらの知識をすべて盛り込んだ、Remixでそのまま使える完全版の実装コードを紹介します。
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
// --- STEP 1: 表示する元データを用意 ---
// 実際にはRemixのloaderから渡されることが多い、大規模なデータ配列を想定
const allItems = Array.from(Array(10000).keys()).map(i => ({
id: i,
name: `ユーザー ${i}`,
email: `user-${i}@example.com`,
bio: `これはユーザー${i}の自己紹介文です。長文になる可能性も考慮します。`,
}));
// --- STEP 2: リスト1行分の見た目を担当するコンポーネントを定義 ---
// 仮想スクロールのロジックと、見た目のデザインを分離するのが綺麗に書くコツ
type User = typeof allItems[0];
function UserRow({ user }: { user: User }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
padding: '10px 15px',
borderBottom: '1px solid #eee',
background: 'white',
}}>
{/* アバター画像 */}
<img
src={`https://i.pravatar.cc/50?u=${user.email}`}
alt={user.name}
style={{ borderRadius: '50%', marginRight: '15px', width: '50px', height: '50px' }}
/>
{/* ユーザー情報 */}
<div style={{ overflow: 'hidden' }}>
<div style={{ fontWeight: 'bold', fontSize: '16px' }}>{user.name}</div>
<div style={{ color: 'gray', fontSize: '14px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{user.email}</div>
</div>
</div>
);
}
// --- STEP 3: 仮想スクロールを実装したメインコンポーネント ---
export default function VirtualListPage() {
// スクロールさせる親要素(窓)を特定するための「名札」を作成
const parentRef = useRef<HTMLDivElement>(null);
// TanStack Virtualの司令塔である`useVirtualizer`フック
const rowVirtualizer = useVirtualizer({
count: allItems.length, // 1. アイテムの総数を教える
getScrollElement: () => parentRef.current, // 2. どの要素(窓)をスクロールさせるか教える
estimateSize: () => 71, // 3. アイテム1つのおおよその高さを教える (padding 10*2 + img 50 + border 1)
overscan: 5, // 表示領域の上下に、先読みして描画しておくアイテム数
});
return (
<div style={{ fontFamily: 'sans-serif', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* アプリのヘッダーを想定 */}
<header style={{ padding: '15px', borderBottom: '1px solid #ccc', background: '#f8f8f8' }}>
<h1>仮想スクロール (10,000件)</h1>
</header>
{/* ▼ STEP 4: スクロールする親コンテナ(窓)を作成 ▼ */}
<div
ref={parentRef} // ここで「名札」を貼る
style={{
// このコンテナがリスト全体の高さを決める
// '100%' と 'flex: 1' で親要素の残りの高さいっぱいに広がる
flex: 1,
width: '100%',
overflow: 'auto', // これがないとスクロールしない!
}}
>
{/* ▼ STEP 5: 全体の高さを確保するための「見せかけ」のコンテナ ▼ */}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`, // ライブラリが計算した「全アイテム分の高さ」を設定
width: '100%',
position: 'relative',
}}
>
{/* ▼ STEP 6: 見えているアイテムだけを描画 ▼ */}
{rowVirtualizer.getVirtualItems().map(virtualItem => {
// ライブラリの指示書を元に、表示すべきデータを取得
const item = allItems[virtualItem.index];
return (
<div
key={item.id} // keyにはユニークな値を指定
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
// これが魔法!正しい位置にアイテムを瞬間移動させる
transform: `translateY(${virtualItem.start}px)`,
}}
>
{/* 1行分の見た目は専用コンポーネントに任せる */}
<UserRow user={item} />
</div>
);
})}
</div>
</div>
</div>
);
}