mcpField
T
tanstack-virtual

tanstack-virtual

github.com/tanstack-skills開発362.5k 回インストール39 週間前に更新
リポジトリ

tanstack-virtual について

TanStack Virtual は、大量のデータを表示する画面を開発する人向けに、リスト、グリッド、テーブルの仮想化ロジックを提供します。表示範囲の項目だけを計算して配置し、DOM ノード数を抑えます。コアパッケージは特定のフレームワークに依存しません。

主な機能

  1. 01表示範囲内の項目だけを描画
  2. 02リスト、グリッド、テーブルに対応
  3. 03動的な項目サイズの測定
  4. 04横スクロールとウィンドウスクロール
  5. 05スクロール位置とオーバースキャンの設定

活用シーン

  1. 01大量の項目を含むリストの表示
  2. 02仮想化グリッドやテーブルの構築
  3. 03高さが変わるリスト項目への対応
  4. 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>
  )
}

仮想化機能のオプション

必須
オプション型説明
countnumber項目の総数
getScrollElement() => Element | nullスクロールコンテナーを返します
estimateSize(index) => number項目サイズの推定値(大きめの値を推奨)
任意
オプション型デフォルト説明
overscannumber1ビューポート外に追加でレンダリングする項目数
horizontalbooleanfalse水平方向の仮想化
gapnumber0項目間の間隔(px)
lanesnumber1レーン数(メイソンリー/グリッド)
paddingStartnumber0最初の項目の前のパディング
paddingEndnumber0最後の項目の後のパディング
scrollPaddingStartnumber0scrollTo の位置指定用オフセット
scrollPaddingEndnumber0scrollTo の位置指定用オフセット
initialOffsetnumber0スクロール開始位置
initialRectRect-初期サイズ(SSR)
enabledbooleantrue有効/無効の切り替え
getItemKey(index) => Key(i) => i項目の安定したキー
rangeExtractor(range) => number[]デフォルト表示するインデックスをカスタマイズ
scrollToFn(offset, options, instance) => voidデフォルトスクロール動作をカスタマイズ
measureElement(el, entry, instance) => numberデフォルトサイズ測定をカスタマイズ
onChange(instance, sync) => void-状態変更時のコールバック
isScrollingResetDelaynumber150スクロール完了までの遅延

仮想化機能の 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' })

ベストプラクティス

  1. estimateSize は大きめに見積もる - スクロール位置のジャンプを防ぎます(項目が縮むと問題が発生します)
  2. overscan を増やす(3~5)と、高速スクロール中の空白表示を減らせます
  3. 配置には top よりも transform: translateY() を使用すると、GPU 合成が利用されます
  4. 動的なサイズ設定で measureElement を使う場合は、data-index 属性を追加する
  5. 動的に測定する項目に固定の高さを設定しない
  6. 項目の並べ替えが可能な場合は、安定したキーとして getItemKey を使用する
  7. マージンではなく gap オプションを使用する(マージンはサイズ測定に影響します)
  8. コンテナーの CSS パディングではなく、paddingStart/End を使用する
  9. リストが非表示のときに一時停止するには enabled: false を使用する
  10. コールバック(estimateSize、getItemKey、rangeExtractor)をメモ化する
  11. GPU アクセラレーションのため、項目に will-change: transform の CSS を使用する

よくある落とし穴

  • 動的に測定する項目に固定の高さを設定する
  • gap オプションではなく CSS マージンを使用する
  • measureElement で data-index を指定し忘れる
  • 内側のコンテナーに position: relative を指定し忘れる
  • estimateSize を小さく見積もる(スクロール位置がジャンプする原因になります)
  • 高速スクロールに対して overscan を小さく設定する(項目が空白になります)
  • ウィンドウスクロール時に translateY から scrollMargin を差し引き忘れる
  • estimateSize 関数をメモ化しない(再レンダリングの原因になります)

コメント