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. 01构建 Cloudflare 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 问题 #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 问题 #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 计算中内存使用过多,处理 50k 行时,渲染时间可能从 <1 秒增加到 30-40 秒。

来源:博客文章(JP Camara) | GitHub 问题 #5926

验证依据:社区测试 + GitHub 问题报告

预防措施:

// 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:查询状态与表格状态不匹配

  • 错误:查询重新获取数据,但分页状态未同步,导致数据过时
  • 原因:查询键缺少表格状态(分页、筛选器、排序)
  • 修复:在查询键中包含所有状态:queryKey: ['users', pagination, columnFilters, sorting]

问题 #3:服务端功能不起作用

  • 错误:分页/筛选/排序不会触发 API 调用
  • 原因:缺少 manual* 标志
  • 修复:设置 manualPagination: true、manualFiltering: true、manualSorting: true,并提供 pageCount

问题 #4:TypeScript“找不到模块”

  • 错误:导入 createColumnHelper 时出错
  • 修复:从 @tanstack/react-table 导入(而非 @tanstack/table-core)

问题 #5:服务端排序不起作用

  • 错误:点击排序标题后数据未更新
  • 原因:排序状态未包含在查询键/API 参数中
  • 修复:在查询键中包含 sorting,在 API 调用中添加排序参数,设置 manualSorting: true + onSortingChange

问题 #6:性能不佳(1000+ 行)

  • 错误:大型数据集下表格运行缓慢/卡顿
  • 修复:客户端使用 TanStack Virtual,或实现服务端分页 问题 #7:与 React Compiler 不兼容(React 19+)
  • 错误:启用 React Compiler 时,"Table doesn't re-render when data changes"
  • 来源:GitHub 问题 #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 中推出。

问题 #8:服务端分页行选择错误

  • 错误:toggleAllRowsSelected(false) 只会取消选择当前页,而不是所有页面
  • 来源:GitHub 问题 #5929
  • 发生原因:选择状态会跨页面保留(这是为了支持服务端使用场景而设计的),但表头复选框状态的计算有误
  • 预防措施:关闭选择时手动清除选择状态:
const toggleAllRows = (value: boolean) => {
  if (!value) {
    table.setRowSelection({}) // Clear entire selection object
  } else {
    table.toggleAllRowsSelected(true)
  }
}

问题 #9:客户端分页的 onPaginationChange 返回错误的 pageIndex

  • 错误:onPaginationChange 始终返回 pageIndex: 0,而不是当前页
  • 来源:GitHub 问题 #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,
})

问题 #10:移除数据时未清理行选择

  • 错误:数据中已不存在的选中行仍保留在选择状态中
  • 来源:GitHub 问题 #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

问题 #11:打开 React DevTools 时性能下降

  • 错误:打开 React DevTools 时,表格性能显著下降(仅限开发环境)
  • 发生原因:DevTools 会在每次渲染时检查表格实例和行模型,行数达到 500+ 时尤为明显
  • 修复方法:进行性能测试时关闭 React DevTools。这不是生产环境问题。

问题 #12:分组列中 TypeScript 的 getValue() 类型推断

  • 错误:在 columnHelper.group() 中,getValue() 返回 unknown,而不是访问器的实际类型
  • 来源:GitHub 问题 #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。

评论

支持 Markdown