mcpField
T
tanstack-table

tanstack-table

github.com/jezweb開発1.1k748 回インストール3 週間前に更新
リポジトリ

tanstack-table について

React で TanStack Table を使う開発者向けの実践スキルです。サーバー側の操作や仮想化、よくある問題への対処法を扱います。

主な機能

  1. 01サーバー側のページネーション、フィルタリング、ソート
  2. 02大規模な行データの仮想化表示
  3. 03列と行の固定
  4. 04行の展開とグループ化
  5. 05よくある連携問題のトラブルシューティング

活用シーン

  1. 01Cloudflare Workers と D1 のデータテーブル構築
  2. 02大規模データセットの仮想化表示
  3. 03テーブル状態や再レンダリング問題の調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/jezweb/claude-skills --skill tanstack-table

インストール後、Claude Code で/tanstack-tableを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
.claude-plugin/
plugin.json
references/
cloudflare-d1-examples.md
common-errors.md
performance-virtualization.md
query-integration.md
server-side-patterns.md
rules/
tanstack-table.md
templates/
basic-client-table.tsx
column-configuration.tsx
d1-database-example.tsx
package.json
server-paginated-table.tsx
shadcn-styled-table.tsx
virtualized-large-dataset.tsx

詳細

TanStack Table

Cloudflare Workers + D1 向けの、サーバーサイドのページネーション、フィルタリング、ソート、仮想化に対応したヘッドレスデータテーブル


クイックスタート

最終更新日:2026-01-09 バージョン:@tanstack/[email protected]、@tanstack/[email protected]

npm install @tanstack/react-table@latest
npm install @tanstack/react-virtual@latest  # For virtualization

基本的なセットアップ(重要:無限再レンダリングを防ぐため、データと列をメモ化してください):

import { useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'

const columns: ColumnDef<User>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' },
]

function UsersTable() {
  const data = useMemo(() => [...users], []) // Stable reference
  const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map(group => (
          <tr key={group.id}>
            {group.headers.map(h => <th key={h.id}>{h.column.columnDef.header}</th>)}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id}>
            {row.getVisibleCells().map(cell => <td key={cell.id}>{cell.renderValue()}</td>)}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

サーバーサイドのパターン

Cloudflare D1 API(ページネーション + フィルタリング + ソート):

// Workers API: functions/api/users.ts
export async function onRequestGet({ request, env }) {
  const url = new URL(request.url)
  const page = Number(url.searchParams.get('page')) || 0
  const pageSize = 20
  const search = url.searchParams.get('search') || ''
  const sortBy = url.searchParams.get('sortBy') || 'created_at'
  const sortOrder = url.searchParams.get('sortOrder') || 'DESC'

  const { results } = await env.DB.prepare(`
    SELECT * FROM users
    WHERE name LIKE ? OR email LIKE ?
    ORDER BY ${sortBy} ${sortOrder}
    LIMIT ? OFFSET ?
  `).bind(`%${search}%`, `%${search}%`, pageSize, page * pageSize).all()

  const { total } = await env.DB.prepare('SELECT COUNT(*) as total FROM users').first()

  return Response.json({
    data: results,
    pagination: { page, pageSize, total, pageCount: Math.ceil(total / pageSize) },
  })
}

クライアントサイド(TanStack Query + Table):

const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
const [columnFilters, setColumnFilters] = useState([])
const [sorting, setSorting] = useState([])

// CRITICAL: Include ALL state in query key
const { data, isLoading } = useQuery({
  queryKey: ['users', pagination, columnFilters, sorting],
  queryFn: async () => {
    const params = new URLSearchParams({
      page: pagination.pageIndex,
      search: columnFilters.find(f => f.id === 'search')?.value || '',
      sortBy: sorting[0]?.id || 'created_at',
      sortOrder: sorting[0]?.desc ? 'DESC' : 'ASC',
    })
    return fetch(`/api/users?${params}`).then(r => r.json())
  },
})

const table = useReactTable({
  data: data?.data ?? [],
  columns,
  getCoreRowModel: getCoreRowModel(),
  // CRITICAL: manual* flags tell table server handles these
  manualPagination: true,
  manualFiltering: true,
  manualSorting: true,
  pageCount: data?.pagination.pageCount ?? 0,
  state: { pagination, columnFilters, sorting },
  onPaginationChange: setPagination,
  onColumnFiltersChange: setColumnFilters,
  onSortingChange: setSorting,
})

仮想化(1000 行以上)

パフォーマンス向上のため、表示中の行だけをレンダリングします:

import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualizedTable() {
  const containerRef = useRef<HTMLDivElement>(null)
  const table = useReactTable({ data: largeDataset, columns, getCoreRowModel: getCoreRowModel() })
  const { rows } = table.getRowModel()

  const rowVirtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => containerRef.current,
    estimateSize: () => 50, // Row height px
    overscan: 10,
  })

  return (
    <div ref={containerRef} style={{ height: '600px', overflow: 'auto' }}>
      <table style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
        <tbody>
          {rowVirtualizer.getVirtualItems().map(virtualRow => {
            const row = rows[virtualRow.index]
            return (
              <tr key={row.id} style={{ position: 'absolute', transform: `translateY(${virtualRow.start}px)` }}>
                {row.getVisibleCells().map(cell => <td key={cell.id}>{cell.renderValue()}</td>)}
              </tr>
            )
          })}
        </tbody>
      </table>
    </div>
  )
}
警告:非表示のコンテナ(タブ/モーダル)

既知の問題:タブコンテンツや、非アクティブなコンテンツを display: none で非表示にするモーダル内で仮想化を使用すると、非表示の間も仮想化機能がレイアウト計算を続けるため、次の問題が発生します:

  • 無限再レンダリングループ(大規模データセット:50k 行以上)
  • タブを表示した際のスクロール位置のずれ
  • テーブルが空になる、またはスクロール位置がリセットされる(小規模データセット)

ソース:GitHub Issue #6109

防止策:

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => containerRef.current,
  estimateSize: () => 50,
  overscan: 10,
  // Disable when container is hidden to prevent infinite re-renders
  enabled: containerRef.current?.getClientRects().length !== 0,
})

// OR: Conditionally render instead of hiding with CSS
{isVisible && <VirtualizedTable />}

列/行の固定

列または行を固定して、横/縦スクロール中も表示し続けます:

import { useReactTable, getCoreRowModel } from '@tanstack/react-table'

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  // Enable pinning
  enableColumnPinning: true,
  enableRowPinning: true,
  // Initial pinning state
  initialState: {
    columnPinning: {
      left: ['select', 'name'],  // Pin to left
      right: ['actions'],        // Pin to right
    },
  },
})

// Render with pinned columns
function PinnedTable() {
  return (
    <div className="flex">
      {/* Left pinned columns */}
      <div className="sticky left-0 bg-background z-10">
        {table.getLeftHeaderGroups().map(/* render left headers */)}
        {table.getRowModel().rows.map(row => (
          <tr>{row.getLeftVisibleCells().map(/* render cells */)}</tr>
        ))}
      </div>

      {/* Center scrollable columns */}
      <div className="overflow-x-auto">
        {table.getCenterHeaderGroups().map(/* render center headers */)}
        {table.getRowModel().rows.map(row => (
          <tr>{row.getCenterVisibleCells().map(/* render cells */)}</tr>
        ))}
      </div>

      {/* Right pinned columns */}
      <div className="sticky right-0 bg-background z-10">
        {table.getRightHeaderGroups().map(/* render right headers */)}
        {table.getRowModel().rows.map(row => (
          <tr>{row.getRightVisibleCells().map(/* render cells */)}</tr>
        ))}
      </div>
    </div>
  )
}

// Toggle pinning programmatically
column.pin('left')   // Pin column to left
column.pin('right')  // Pin column to right
column.pin(false)    // Unpin column
row.pin('top')       // Pin row to top
row.pin('bottom')    // Pin row to bottom
警告:列グループでの列の固定

既知の問題:親グループ列(columnHelper.group() で作成)を固定すると、位置がずれたり、ヘッダーが重複したりします。グループヘッダーでは column.getStart('left') が誤った値を返します。

ソース:GitHub Issue #5397

防止策:

// Disable pinning for grouped columns
const isPinnable = (column) => !column.parent

// OR: Pin individual columns within group, not the group itself
table.getColumn('firstName')?.pin('left')
table.getColumn('lastName')?.pin('left')
// Don't pin the parent group column

行の展開(ネストされたデータ)

子行や追加の詳細情報の表示/非表示を切り替えます:

import { useReactTable, getCoreRowModel, getExpandedRowModel } from '@tanstack/react-table'

// Data with nested children
const data = [
  {
    id: 1,
    name: 'Parent Row',
    subRows: [
      { id: 2, name: 'Child Row 1' },
      { id: 3, name: 'Child Row 2' },
    ],
  },
]

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getExpandedRowModel: getExpandedRowModel(),  // Required for expanding
  getSubRows: row => row.subRows,               // Tell table where children are
})

// Render with expand button
function ExpandableTable() {
  return (
    <tbody>
      {table.getRowModel().rows.map(row => (
        <>
          <tr key={row.id}>
            <td>
              {row.getCanExpand() && (
                <button onClick={row.getToggleExpandedHandler()}>
                  {row.getIsExpanded() ? '▼' : '▶'}
                </button>
              )}
            </td>
            {row.getVisibleCells().map(cell => (
              <td key={cell.id} style={{ paddingLeft: `${row.depth * 20}px` }}>
                {cell.renderValue()}
              </td>
            ))}
          </tr>
        </>
      ))}
    </tbody>
  )
}

// Control expansion programmatically
table.toggleAllRowsExpanded()     // Expand/collapse all
row.toggleExpanded()              // Toggle single row
table.getIsAllRowsExpanded()      // Check if all expanded

詳細行(ネストされたデータではなく、カスタムコンテンツ):

function DetailRow({ row }) {
  if (!row.getIsExpanded()) return null

  return (
    <tr>
      <td colSpan={columns.length}>
        <div className="p-4 bg-muted">
          Custom detail content for row {row.id}
        </div>
      </td>
    </tr>
  )
}

行のグループ化

列の値で行をグループ化します:

import { useReactTable, getCoreRowModel, getGroupedRowModel } from '@tanstack/react-table'

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getGroupedRowModel: getGroupedRowModel(),    // Required for grouping
  getExpandedRowModel: getExpandedRowModel(),  // Groups are expandable
  initialState: {
    grouping: ['status'],  // Group by 'status' column
  },
})

// Column with aggregation
const columns = [
  {
    accessorKey: 'status',
    header: 'Status',
  },
  {
    accessorKey: 'amount',
    header: 'Amount',
    aggregationFn: 'sum',                      // Sum grouped values
    aggregatedCell: ({ getValue }) => `Total: ${getValue()}`,
  },
]

// Render grouped table
function GroupedTable() {
  return (
    <tbody>
      {table.getRowModel().rows.map(row => (
        <tr key={row.id}>
          {row.getVisibleCells().map(cell => (
            <td key={cell.id}>
              {cell.getIsGrouped() ? (
                // Grouped cell - show group header with expand toggle
                <button onClick={row.getToggleExpandedHandler()}>
                  {row.getIsExpanded() ? '▼' : '▶'} {cell.renderValue()} ({row.subRows.length})
                </button>
              ) : cell.getIsAggregated() ? (
                // Aggregated cell - show aggregation result
                cell.renderValue()
              ) : cell.getIsPlaceholder() ? null : (
                // Regular cell
                cell.renderValue()
              )}
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  )
}

// Built-in aggregation functions
// 'sum', 'min', 'max', 'extent', 'mean', 'median', 'unique', 'uniqueCount', 'count'
警告:グループ化によるパフォーマンスのボトルネック(コミュニティからの情報)

既知の問題:グループ化機能を使うと、中規模から大規模なデータセットでパフォーマンスが大幅に低下します。グループ化を有効にすると、createRow の計算でメモリを過剰に使用するため、5 万行ではレンダリング時間が 1 秒未満から 30~40 秒に増えることがあります。

ソース:ブログ記事(JP Camara) | GitHub Issue #5926

確認状況:コミュニティでのテスト + GitHub issue の報告

防止策:

// 1. Use server-side grouping for large datasets
// 2. Implement pagination to limit rows per page
// 3. Disable grouping for 10k+ rows
const shouldEnableGrouping = data.length < 10000

// 4. OR: Use React.memo on row components
const MemoizedRow = React.memo(TableRow)

既知の問題と解決策

問題 #1:無限再レンダリング

  • エラー:テーブルが無限に再レンダリングされ、ブラウザーがフリーズする
  • 原因:レンダリングのたびに data または columns の参照が変わる
  • 修正方法:useMemo(() => [...], []) を使うか、コンポーネントの外でデータ/列を定義する

問題 #2:Query と Table の状態の不一致

  • エラー:Query は再取得するが、ページネーションの状態が同期されず、古いデータが表示される
  • 原因:クエリーキーにテーブルの状態(ページネーション、フィルター、ソート)が含まれていない
  • 修正方法:すべての状態をクエリーキーに含める:queryKey: ['users', pagination, columnFilters, sorting]

問題 #3:サーバーサイド機能が動作しない

  • エラー:ページネーション/フィルタリング/ソートを実行しても API が呼び出されない
  • 原因:manual* フラグが不足している
  • 修正方法:manualPagination: true、manualFiltering: true、manualSorting: true を設定し、pageCount を指定する

問題 #4:TypeScript の「Cannot Find Module」

  • エラー:createColumnHelper のインポートエラー
  • 修正方法:@tanstack/react-table からインポートする(@tanstack/table-core からではありません)

問題 #5:サーバーサイドのソートが動作しない

  • エラー:ソート用ヘッダーをクリックしてもデータが更新されない
  • 原因:ソートの状態がクエリーキー/API パラメーターに含まれていない
  • 修正方法:クエリーキーに sorting を含め、API 呼び出しにソート用パラメーターを追加し、manualSorting: true と onSortingChange を設定する

問題 #6:パフォーマンスが低い(1000 行以上)

  • エラー:大規模なデータセットでテーブルの動作が遅い/引っかかる
  • 修正方法:クライアントサイドでは TanStack Virtual を使うか、サーバーサイドのページネーションを実装する Issue #7: React Compiler との非互換性(React 19 以降)
  • エラー: "Table doesn't re-render when data changes"(React Compiler が有効な場合)
  • ソース: GitHub Issue #5567
  • 発生する理由: React Compiler の自動メモ化がテーブルのコアインスタンスと競合し、データや状態が変化しても再レンダリングされません
  • 予防策: useReactTable を使用するコンポーネントの先頭に "use no memo" ディレクティブを追加します。
"use no memo"

function TableComponent() {
  const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
  // Now works correctly with React Compiler
}

注: この問題は、カラムの表示・非表示と行選択にも影響します。完全な修正は v9 で提供される予定です。

Issue #8: サーバーサイドのページネーションで発生する行選択のバグ

  • エラー: toggleAllRowsSelected(false) を呼び出しても、現在のページの選択しか解除されません
  • ソース: GitHub Issue #5929
  • 発生する理由: 選択状態はページをまたいで保持されます(サーバーサイドのユースケースを想定した仕様です)が、ヘッダーのチェックボックスの状態が正しく計算されません
  • 予防策: 選択を解除するときに、選択状態を手動でクリアします。
const toggleAllRows = (value: boolean) => {
  if (!value) {
    table.setRowSelection({}) // Clear entire selection object
  } else {
    table.toggleAllRowsSelected(true)
  }
}

Issue #9: クライアントサイドの onPaginationChange が誤った pageIndex を返す

  • エラー: onPaginationChange が現在のページではなく、常に pageIndex: 0 を返します
  • ソース: GitHub Issue #5970
  • 発生する理由: クライアントサイドのページネーションモードに状態追跡のバグがあります(クライアントモードでのみ発生し、サーバー/手動モードでは正しく動作します)
  • 予防策: 正しく動作させるには、手動ページネーションに切り替えます。
// Instead of relying on client-side pagination
const table = useReactTable({
  data,
  columns,
  manualPagination: true, // Forces correct state tracking
  pageCount: Math.ceil(data.length / pagination.pageSize),
  state: { pagination },
  onPaginationChange: setPagination,
})

Issue #10: データ削除時に行選択がクリアされない

  • エラー: データ内に存在しなくなった選択済みの行が、選択状態に残ります
  • ソース: GitHub Issue #5850
  • 発生する理由: サーバーサイドのページネーションをサポートするための仕様です(現在のページから行が消えても、選択状態を維持する必要があります)
  • 予防策: データを削除するときに、選択状態を手動でクリーンアップします。
const removeRow = (idToRemove: string) => {
  // Remove from data
  setData(data.filter(row => row.id !== idToRemove))

  // Clean up selection if it was selected
  const { rowSelection } = table.getState()
  if (rowSelection[idToRemove]) {
    table.setRowSelection((old) => {
      const filtered = Object.entries(old).filter(([id]) => id !== idToRemove)
      return Object.fromEntries(filtered)
    })
  }
}

// OR: Use table.resetRowSelection(true) to clear all

Issue #11: React DevTools を開いているとパフォーマンスが低下する

  • エラー: React DevTools を開いていると、テーブルのパフォーマンスが大幅に低下します(開発時のみ)
  • 発生する理由: DevTools はレンダリングのたびにテーブルインスタンスと行モデルを調査します。特に 500 行を超える場合に顕著です
  • 修正方法: パフォーマンスのテスト中は React DevTools を閉じてください。本番環境では発生しません。

Issue #12: グループ化されたカラムでの TypeScript getValue() の型推論

  • エラー: columnHelper.group() 内で getValue() がアクセサーの実際の型ではなく unknown を返します
  • ソース: GitHub Issue #5860
  • 修正方法: 型を手動で指定するか、renderValue() を使用します。
// Option 1: Type assertion
cell: (info) => {
  const value = info.getValue() as string
  return value.toUpperCase()
}

// Option 2: Use renderValue() (better type inference)
cell: (info) => {
  const value = info.renderValue()
  return typeof value === 'string' ? value.toUpperCase() : value
}

関連スキル: tanstack-query(データ取得)、cloudflare-d1(データベースバックエンド)、tailwind-v4-shadcn(UI スタイリング)


最終確認日: 2026-01-21 | スキルバージョン: 2.0.0 | 変更内容: TIER 1〜2 の調査結果から、新たに判明した 7 件の既知の問題を追加しました(React 19 Compiler、サーバーサイドの行選択、非表示コンテナー内の仮想化、クライアントサイドのページネーションのバグ、グループを含むカラムのピン留め、行選択のクリーンアップ、DevTools のパフォーマンス、TypeScript getValue)。エラー数: 6 → 12。

コメント