tanstack-virtual
TS/JS、React、Vue、Solid、Svelte、Lit、Angularに対応し、大量の要素リストを60FPSで仮想化するためのヘッドレスUIライブラリです。スクロールパフォーマンスが求められる大規模リストやテーブルの実装時に活用できます。
description の原文を見る
Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular.
SKILL.md 本文
概要
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>
)
}
Virtualizer オプション
必須
| オプション | 型 | 説明 |
|---|---|---|
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 | スクロール完了までの遅延 |
Virtualizer API
// 表示されている要素を取得
virtualizer.getVirtualItems(): VirtualItem[]
// スクロール可能な総サイズを取得
virtualizer.getTotalSize(): number
// 特定のインデックスにスクロール
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// オフセットにスクロール
virtualizer.scrollToOffset(offset, options)
// 再計算を強制
virtualizer.measure()
VirtualItem プロパティ
interface VirtualItem {
key: Key // ユニークキー
index: number // ソース データ内のインデックス
start: number // ピクセル オフセット (transform に使用)
end: number // 終了ピクセル オフセット
size: number // 要素の寸法
lane: number // レーン インデックス (マルチ カラム)
}
動的/可変高さ
未知の高さを持つ要素に 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} // 計測のために必須
ref={virtualizer.measureElement} // 動的計測用にアタッチ
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
// 固定高さを設定しないでください - コンテンツが決定させます
}}
>
{items[virtualItem.index].content}
</div>
))}
水平方向仮想化
const virtualizer = useVirtualizer({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
})
// コンテナの幅に translateX を使用して配置
<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 つの Virtualizer)
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])
// 仮想要素をレンダリング、読み込み中の場合は最後の要素に読み込みローダーを表示
}
スティッキー要素
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // ヘッダー インデックス
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]),
})
// スティッキー要素を position: sticky; top: 0; zIndex: 1 でレンダリング
スムーズ スクロール
const virtualizer = useVirtualizer({
scrollToFn: (offset, { behavior }, instance) => {
if (behavior === 'smooth') {
// カスタム イージング アニメーション
instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })
} else {
instance.scrollElement?.scrollTo({ top: offset })
}
},
})
// 使用方法
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
ベスト プラクティス
estimateSizeを多めに見積もる - スクロール ジャンプを防止 (要素が縮小するとトラブル)overscanを増やす (3-5) - 高速スクロール時の空白フラッシュを削減topの代わりにtransform: translateY()を使用 - GPU コンポジット配置に- 動的サイズ変更で
measureElementを使用する場合はdata-index属性を追加 - 動的に計測された要素に固定高さを設定しない
- 要素が並び替わる可能性がある場合は
getItemKeyを使用 安定したキーのために - CSS マージンの代わりに
gapオプションを使用 (マージンは計測に干渉) - コンテナ上の CSS パディングの代わりに
paddingStart/Endを使用 - リストが非表示の場合は
enabled: falseを使用 一時停止に - コールバックをメモ化 (
estimateSize,getItemKey,rangeExtractor) - 要素上で
will-change: transformCSS を使用 GPU アクセラレーション用
よくある落とし穴
- 動的に計測される要素に固定高さを設定
- CSS マージンの代わりに
gapオプションを使用していない measureElementでdata-indexを忘れる- 内側のコンテナに
position: relativeを指定していない estimateSizeを少なめに見積もる (スクロール ジャンプの原因)overscanを低く設定して高速スクロール (空白要素が発生)- ウィンドウ スクロール時に
translateYからscrollMarginを差し引くのを忘れる estimateSize関数をメモ化していない (再レンダリングの原因)
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- tanstack-skills
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/tanstack-skills/tanstack-skills / ライセンス: MIT
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。