tanstack-virtual について
TanStack Virtual は、大量のデータを表示する画面を開発する人向けに、リスト、グリッド、テーブルの仮想化ロジックを提供します。表示範囲の項目だけを計算して配置し、DOM ノード数を抑えます。コアパッケージは特定のフレームワークに依存しません。
主な機能
- 01表示範囲内の項目だけを描画
- 02リスト、グリッド、テーブルに対応
- 03動的な項目サイズの測定
- 04横スクロールとウィンドウスクロール
- 05スクロール位置とオーバースキャンの設定
活用シーン
- 01大量の項目を含むリストの表示
- 02仮想化グリッドやテーブルの構築
- 03高さが変わるリスト項目への対応
- 04長いリストへの無限スクロール追加
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ tanstack-skills/ tanstack-skills --skill tanstack-virtualインストール後、Claude Code で/tanstack-virtualを入力して読み込まれたか確認します。
詳細
概要
TanStack Virtual は、大規模なリスト、グリッド、テーブルで表示中の項目のみをレンダリングするための仮想化ロジックを提供します。ビューポート内の項目を計算し、絶対配置で配置することで、データセットのサイズにかかわらず DOM ノード数を最小限に抑えます。
パッケージ: @tanstack/react-virtual
コア: @tanstack/virtual-core(フレームワーク非依存)
インストール
npm install @tanstack/react-virtual
基本パターン
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // estimated row height in px
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.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>
)
}
仮想化機能のオプション
必須
| オプション | 型 | 説明 |
|---|---|---|
count | number | 項目の総数 |
getScrollElement | () => Element | null | スクロールコンテナーを返します |
estimateSize | (index) => number | 項目サイズの推定値(大きめの値を推奨) |
任意
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
overscan | number | 1 | ビューポート外に追加でレンダリングする項目数 |
horizontal | boolean | false | 水平方向の仮想化 |
gap | number | 0 | 項目間の間隔(px) |
lanes | number | 1 | レーン数(メイソンリー/グリッド) |
paddingStart | number | 0 | 最初の項目の前のパディング |
paddingEnd | number | 0 | 最後の項目の後のパディング |
scrollPaddingStart | number | 0 | scrollTo の位置指定用オフセット |
scrollPaddingEnd | number | 0 | scrollTo の位置指定用オフセット |
initialOffset | number | 0 | スクロール開始位置 |
initialRect | Rect | - | 初期サイズ(SSR) |
enabled | boolean | true | 有効/無効の切り替え |
getItemKey | (index) => Key | (i) => i | 項目の安定したキー |
rangeExtractor | (range) => number[] | デフォルト | 表示するインデックスをカスタマイズ |
scrollToFn | (offset, options, instance) => void | デフォルト | スクロール動作をカスタマイズ |
measureElement | (el, entry, instance) => number | デフォルト | サイズ測定をカスタマイズ |
onChange | (instance, sync) => void | - | 状態変更時のコールバック |
isScrollingResetDelay | number | 150 | スクロール完了までの遅延 |
仮想化機能の API
// Get visible items
virtualizer.getVirtualItems(): VirtualItem[]
// Get total scrollable size
virtualizer.getTotalSize(): number
// Scroll to specific index
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// Scroll to offset
virtualizer.scrollToOffset(offset, options)
// Force recalculation
virtualizer.measure()
VirtualItem のプロパティ
interface VirtualItem {
key: Key // Unique key
index: number // Index in source data
start: number // Pixel offset (use for transform)
end: number // End pixel offset
size: number // Item dimension
lane: number // Lane index (multi-column)
}
動的/可変の高さ
高さが不明な項目には measureElement ref を使用します。
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // overestimate
})
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index} // REQUIRED for measurement
ref={virtualizer.measureElement} // Attach for dynamic measurement
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
// Do NOT set fixed height - let content determine it
}}
>
{items[virtualItem.index].content}
</div>
))}
水平方向の仮想化
const virtualizer = useVirtualizer({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
})
// Use width for container, translateX for positioning
<div style={{ width: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div style={{
position: 'absolute',
height: '100%',
width: `${item.size}px`,
transform: `translateX(${item.start}px)`,
}}>
Column {item.index}
</div>
))}
</div>
グリッドの仮想化(2 つの仮想化機能)
function VirtualGrid() {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
})
const columnVirtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '500px', width: '500px', overflow: 'auto' }}>
<div style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
Cell {virtualRow.index},{virtualColumn.index}
</div>
))}
</Fragment>
))}
</div>
</div>
)
}
ウィンドウスクロール
import { useWindowVirtualizer } from '@tanstack/react-virtual'
function WindowList() {
const listRef = useRef<HTMLDivElement>(null)
const virtualizer = useWindowVirtualizer({
count: 10000,
estimateSize: () => 45,
overscan: 5,
scrollMargin: listRef.current?.offsetTop ?? 0,
})
return (
<div ref={listRef}>
<div style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: 'absolute',
height: `${item.size}px`,
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
>
Row {item.index}
</div>
))}
</div>
</div>
)
}
無限スクロール
import { useVirtualizer } from '@tanstack/react-virtual'
import { useInfiniteQuery } from '@tanstack/react-query'
function InfiniteList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const allItems = data?.pages.flatMap((page) => page.items) ?? []
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
})
useEffect(() => {
const items = virtualizer.getVirtualItems()
const lastItem = items[items.length - 1]
if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length])
// Render virtual items, show loader row for last item if loading
}
スティッキー項目
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // Header indices
const virtualizer = useVirtualizer({
count: 1000,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
rangeExtractor: useCallback((range: Range) => {
const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)])
return [...next].sort((a, b) => a - b)
}, [stickyIndexes]),
})
// Render sticky items with position: sticky; top: 0; zIndex: 1
スムーズスクロール
const virtualizer = useVirtualizer({
scrollToFn: (offset, { behavior }, instance) => {
if (behavior === 'smooth') {
// Custom easing animation
instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })
} else {
instance.scrollElement?.scrollTo({ top: offset })
}
},
})
// Usage
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
ベストプラクティス
estimateSizeは大きめに見積もる - スクロール位置のジャンプを防ぎます(項目が縮むと問題が発生します)overscanを増やす(3~5)と、高速スクロール中の空白表示を減らせます- 配置には
topよりもtransform: translateY()を使用すると、GPU 合成が利用されます - 動的なサイズ設定で
measureElementを使う場合は、data-index属性を追加する - 動的に測定する項目に固定の高さを設定しない
- 項目の並べ替えが可能な場合は、安定したキーとして
getItemKeyを使用する - マージンではなく
gapオプションを使用する(マージンはサイズ測定に影響します) - コンテナーの CSS パディングではなく、
paddingStart/Endを使用する - リストが非表示のときに一時停止するには
enabled: falseを使用する - コールバック(
estimateSize、getItemKey、rangeExtractor)をメモ化する - GPU アクセラレーションのため、項目に
will-change: transformの CSS を使用する
よくある落とし穴
- 動的に測定する項目に固定の高さを設定する
gapオプションではなく CSS マージンを使用するmeasureElementでdata-indexを指定し忘れる- 内側のコンテナーに
position: relativeを指定し忘れる estimateSizeを小さく見積もる(スクロール位置がジャンプする原因になります)- 高速スクロールに対して
overscanを小さく設定する(項目が空白になります) - ウィンドウスクロール時に
translateYからscrollMarginを差し引き忘れる estimateSize関数をメモ化しない(再レンダリングの原因になります)
コメント
Markdown 対応