はじめに
大量のデータをリスト表示する場面、実務だと意外と多いんですよね。ユーザー一覧、ログデータ、商品リスト...数百件程度なら問題ないんですけど、これが数千件、数万件になってくるとブラウザが悲鳴を上げ始める。
個人的には「1万件くらいならReactでも大丈夫でしょ」と思っていた時期がありました。でも実際にやってみると、スクロールがカクつく、初期表示が遅い、メモリ使用量がヤバい、という三重苦。30代になって思うのは、「理論上可能」と「実用的」は全然違うということ。
そこで出会ったのがTanStack Virtualです。GitHubで6,640スター以上、月間2,500万ダウンロード、35万以上のプロジェクトで使われている仮想化ライブラリ。これを導入したら、10万件のデータでも60FPSでヌルヌル動くようになったので、その経緯を共有します。
TanStack Virtualとは
TanStack Virtualは、Tanner Linsley氏率いるTanStackが開発している仮想化ライブラリです。「Headless UI for Virtualizing Large Element Lists」というキャッチコピーの通り、大量の要素を効率的にレンダリングするためのツール。
仮想化(Virtualization)というのは、画面に見えている部分だけをDOMにレンダリングして、見えていない部分は描画しないという技術です。10万件のリストがあっても、実際にDOMに存在するのは画面に収まる数十件だけ。だから軽い。
現在の最新バージョンはv3.13.18で、MITライセンスのオープンソース。TypeScriptで98%以上書かれていて、130人以上のコントリビューターが開発に参加しています。
ちなみに、React以外にもVue、Solid、Svelte、Angularに対応していて、フレームワークを選ばないのも強みですね。
特徴・メリット
1. 圧倒的なパフォーマンス
これ、数字で見ると衝撃的なんですけど、10万件のリストを普通にmapで回すとブラウザがフリーズするレベル。でもTanStack Virtualを使うと60FPSでスムーズにスクロールできる。
実際に計測してみると、DOM要素数が数十個に抑えられているので、メモリ使用量も激減します。コスパ的に、この効果は計り知れない。
2. ヘッドレス設計で自由度が高い
「ヘッドレス」というのは、UIのスタイルを一切持たないという意味です。見た目は完全に自分でコントロールできる。CSSフレームワークとの組み合わせも自由自在。
個人的には、この「見た目に口出ししない」というスタンスが好きですね。既存のデザインシステムに組み込みやすい。
3. 軽量(10〜15KB)
バンドルサイズが10〜15KB(gzip圧縮後は約9KB)と非常に軽量。重厚な仮想化ライブラリだとバンドルサイズが膨らみがちですけど、TanStack Virtualはその点も優秀。
4. 多様なレイアウトに対応
- 垂直スクロール(一般的なリスト)
- 水平スクロール(横スクロールのカルーセル風)
- グリッドレイアウト(画像ギャラリーなど)
これ、単一のAPIで全部対応できるんですよ。使い分けも簡単。
5. 動的サイズに対応
リストの各アイテムの高さが異なる場合でも対応できます。テキストの量によって高さが変わるカードUIとかでも問題ない。measureElementという関数で実測してくれる。
インストール方法
npmまたはyarnでインストールします。Reactの場合は@tanstack/react-virtualを使います。
# npm
npm install @tanstack/react-virtual
# yarn
yarn add @tanstack/react-virtual
# pnpm
pnpm add @tanstack/react-virtual
他のフレームワークの場合は以下のパッケージを使います。
# Vue
npm install @tanstack/vue-virtual
# Solid
npm install @tanstack/solid-virtual
# Svelte
npm install @tanstack/svelte-virtual
# Angular
npm install @tanstack/angular-virtual
基本的な使い方
シンプルなリスト表示
まずは最もベーシックな使い方から。1万件のリストを表示してみます。
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function SimpleList() {
// スクロールコンテナへの参照
const parentRef = useRef<HTMLDivElement>(null)
// 仮想化の設定
const rowVirtualizer = useVirtualizer({
count: 10000, // 全体のアイテム数
getScrollElement: () => parentRef.current, // スクロールコンテナ
estimateSize: () => 50, // 各アイテムの推定高さ(px)
})
return (
<div
ref={parentRef}
style={{
height: '500px', // コンテナの高さ
overflow: 'auto', // スクロール可能に
}}
>
{/* 仮想的な全体の高さを持つコンテナ */}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* 実際にレンダリングされるアイテム */}
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
Row {virtualItem.index}
</div>
))}
</div>
</div>
)
}
これだけで1万件のリストがサクサク動きます。ポイントは以下の3つ。
useVirtualizerフックで仮想化の設定を行うgetTotalSize()で仮想的な全体の高さを取得getVirtualItems()で実際にレンダリングすべきアイテムを取得
動的な高さのリスト
各アイテムの高さが異なる場合は、measureElementを使って実測します。
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
interface Item {
id: number
content: string
}
function DynamicList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // 推定値(実測で上書きされる)
})
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={rowVirtualizer.measureElement} // 実測用のref
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div style={{ padding: '16px', borderBottom: '1px solid #eee' }}>
<h3>Item {items[virtualItem.index].id}</h3>
<p>{items[virtualItem.index].content}</p>
</div>
</div>
))}
</div>
</div>
)
}
measureElementを各アイテムのrefに渡すだけで、自動的に高さを計測してくれます。これ、地味にすごい機能なんですよ。
実践的なユースケース
グリッドレイアウト(画像ギャラリー)
画像ギャラリーのようなグリッド表示も実装できます。
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function ImageGallery({ images }: { images: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const columnCount = 4
const rowVirtualizer = useVirtualizer({
count: Math.ceil(images.length / columnCount),
getScrollElement: () => parentRef.current,
estimateSize: () => 200, // 各行の高さ
})
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * columnCount
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'grid',
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gap: '8px',
}}
>
{Array.from({ length: columnCount }).map((_, columnIndex) => {
const imageIndex = startIndex + columnIndex
if (imageIndex >= images.length) return null
return (
<img
key={imageIndex}
src={images[imageIndex]}
alt={`Image ${imageIndex}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)
})}
</div>
)
})}
</div>
</div>
)
}
水平スクロール(カルーセル)
横スクロールのUIも簡単に作れます。
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function HorizontalList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const columnVirtualizer = useVirtualizer({
horizontal: true, // 水平方向に設定
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 200, // 各アイテムの幅
})
return (
<div
ref={parentRef}
style={{
width: '100%',
height: '200px',
overflow: 'auto',
}}
>
<div
style={{
width: `${columnVirtualizer.getTotalSize()}px`,
height: '100%',
position: 'relative',
}}
>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${virtualColumn.size}px`,
transform: `translateX(${virtualColumn.start}px)`,
}}
>
<div
style={{
padding: '16px',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f0f0f0',
margin: '0 4px',
}}
>
{items[virtualColumn.index]}
</div>
</div>
))}
</div>
</div>
)
}
horizontal: trueを設定するだけで水平方向の仮想化に切り替わります。APIが統一されているので覚えやすいですね。
TanStack Tableとの組み合わせ
同じTanStackファミリーのTanStack Tableと組み合わせると、大量データのテーブルも軽快に動作します。
import { useVirtualizer } from '@tanstack/react-virtual'
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table'
import { useRef } from 'react'
function VirtualizedTable({ data, columns }) {
const parentRef = useRef<HTMLDivElement>(null)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
})
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<table style={{ width: '100%' }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
react-windowとの比較
仮想化ライブラリといえばreact-windowも有名ですね。正直なところ、どちらも優秀で基本的なユースケースなら問題なく使える。
ただ、個人的にはTanStack Virtualの方が以下の点で優れていると感じます。
- 動的サイズのサポートが標準:react-windowだと
VariableSizeListとmeasureRefを組み合わせる必要がある - TypeScriptの型定義が充実:TanStackらしく型安全性が高い
- メンテナンスが活発:react-windowは2020年以降更新が減っている
- フレームワーク非依存:Vue、Svelteなどでも同じAPIで使える
react-windowで困っていないなら無理に移行する必要はないですけど、新規プロジェクトならTanStack Virtual一択ですね。
まとめ
TanStack Virtualを導入すると、以下のメリットが得られます。
- 数万件のデータでも60FPSでスクロールできる
- DOM要素数が抑えられてメモリ効率が良い
- ヘッドレス設計で既存のデザインに組み込みやすい
- 軽量(10〜15KB)でバンドルサイズを圧迫しない
- 垂直・水平・グリッドと多様なレイアウトに対応
30代になって思うのは、パフォーマンス問題は「頑張って最適化する」より「専用ツールに任せる」方が効率的だということ。大量データの表示で困っているなら、TanStack Virtualを試してみることをおすすめします。
導入のハードルも低いので、まずは小さなリストコンポーネントから試してみてください。一度その軽さを体験したら、もう普通のmapには戻れなくなりますよ。