关于 tanstack-table
面向 React 开发者的 TanStack Table 实践技能,涵盖服务端表格操作、虚拟化及常见问题处理。
主要功能
- 01服务端分页、筛选与排序
- 02大数据集虚拟化渲染
- 03列与行固定
- 04行展开与分组
- 05常见集成问题排查
适用场景
- 01构建 Cloudflare Workers 与 D1 数据表格
- 02虚拟化渲染大型数据集
- 03排查表格状态与重复渲染问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill tanstack-table安装后在 Claude Code 中输入/tanstack-table确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonreferences/cloudflare-d1-examples.mdcommon-errors.mdperformance-virtualization.mdquery-integration.mdserver-side-patterns.mdrules/tanstack-table.mdtemplates/basic-client-table.tsxcolumn-configuration.tsxd1-database-example.tsxpackage.jsonserver-paginated-table.tsxshadcn-styled-table.tsxvirtualized-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+ 行)
- 选项卡显示时滚动位置错误
- 表格为空或滚动位置重置(小型数据集)
预防措施:
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') 会返回错误的值。
预防措施:
// 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。