12k
All articles

使用 TanStack Table 构建数据表格

使用 TanStack Table 构建数据网格,涵盖排序、筛选、分页和虚拟化,并修复常见的记忆化和无限重渲染问题。

OpenReplay Team
OpenReplay Team
使用 TanStack Table 构建数据表格

TanStack Table 是一个无头(headless)库:它负责表格逻辑(排序、过滤、分页和行模型),而你完全掌控标记结构和样式。

如果你曾经花一个下午与预构建表格的样式表较劲,只为调整一条边框的位置,那么这种取舍对你来说会立刻显得合情合理。你获得的是一个经过实战检验的状态引擎,以及一块完全空白的 DOM 画布——当设计系统由你自己掌控时,这正是你所需要的。

本指南基于当前稳定版本 @tanstack/react-table v8 搭建一个真实的数据表格:一张支持排序、全局过滤和分页的表格。此外,还会深入探讨在生产环境中真正会带来麻烦的两个扩展性问题:导致无限重渲染的 memoization 陷阱,以及面对数千行数据时的虚拟化方案。

核心要点

  • 在 TanStack Table v8 中,所有表头和单元格均须通过 flexRender 渲染:表头使用 flexRender(header.column.columnDef.header, header.getContext()),单元格使用 flexRender(cell.column.columnDef.cell, cell.getContext())。v7 时代的 cell.render('Cell')column.render('Header') 调用方式已不再存在。
  • 排序、过滤和分页均为按需启用的行模型:将 getSortedRowModel()getFilteredRowModel()getPaginationRowModel() 传入同一个 useReactTable 调用即可,注册顺序不影响结果。
  • TanStack Table 中最常见的 bug 是由于每次渲染都传入新的 datacolumns 数组而导致的无限重渲染循环。请将两者都用 useMemo 包裹,或在模块作用域中定义它们。
  • 当客户端行数超过数千条时,应停止使用分页,改用 @tanstack/react-virtualuseVirtualizertable.getRowModel().rows 进行虚拟化渲染。
  • 当前稳定版为 v8,npm latest 标签对应版本为 8.21.3。v9 于 2026 年 6 月进入 beta 阶段,生产环境中的表格请继续使用 v8。

“无头”的含义及其重要性

无头意味着该库只提供行为,不提供 UI。TanStack Table 负责计算行模型并暴露状态与处理函数,但不向 DOM 写入任何内容:v8 移除了默认样式和 role 属性,以使核心库保持框架无关性。这带来的好处是完全的控制权。相应的责任是:语义化标记也由你负责,因此 <table><th scope> 以及任何 ARIA 角色都需要你自行处理。

在实践中,这意味着样式完全由你自带:可以在你自己的 <table> 标记上使用 Tailwind 工具类、CSS Modules 或 styled components。该库从不介入样式层面。

搭建基础 TanStack Table 表格

安装依赖包,并使用 useReactTable hook 构建表格。通过 createColumnHelper 定义列以获得类型推断,传入 datacolumns,并注册 getCoreRowModel()——这是将数据映射为行的基础行模型。

npm install @tanstack/react-table

该适配器支持 React 16.8 至 19 的所有版本,但需注意:在 React 19 随附的 React Compiler 下,其行为可能存在不确定性。

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

type User = { firstName: string; lastName: string; age: number }

const columnHelper = createColumnHelper<User>()

const columns = [
  columnHelper.accessor('firstName', { header: 'First Name' }),
  columnHelper.accessor('lastName', { header: 'Last Name' }),
  columnHelper.accessor('age', { header: 'Age' }),
]

渲染时,依次遍历 getHeaderGroups()getRowModel().rows 以及每行的 getVisibleCells()。将列定义及其上下文传入 flexRender,以便字符串表头、JSX 和组件单元格均能正确解析。根据 v8 迁移指南,flexRender 替代了已移除的 cell.render('Cell') / column.render('Header') 调用方式。

function DataGrid({ data }: { data: User[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map(hg => (
          <tr key={hg.id}>
            {hg.headers.map(header => (
              <th key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(header.column.columnDef.header, header.getContext())}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id}>
            {row.getVisibleCells().map(cell => (
              <td key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

添加排序功能

启用排序需要:添加 getSortedRowModel(),通过 onSortingChange 维护 sorting 状态,并将每个表头与 header.column.getToggleSortingHandler() 关联。使用 header.column.getIsSorted()(返回值为 'asc''desc'false)来显示排序方向指示器。

const [sorting, setSorting] = useState<SortingState>([])

const table = useReactTable({
  data,
  columns,
  state: { sorting },
  onSortingChange: setSorting,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
})
<th onClick={header.column.getToggleSortingHandler()} style={{ cursor: 'pointer' }}>
  {flexRender(header.column.columnDef.header, header.getContext())}
  {{ asc: '', desc: '' }[header.column.getIsSorted() as string] ?? ''}
</th>

过滤与全局搜索

全局搜索只需一个状态:添加 getFilteredRowModel(),在 state 中维护一个 globalFilter 字符串,并通过 onGlobalFilterChange 更新它。默认的全局过滤器会对所有列进行子字符串匹配。

const [globalFilter, setGlobalFilter] = useState('')

const table = useReactTable({
  data,
  columns,
  state: { sorting, globalFilter },
  onSortingChange: setSorting,
  onGlobalFilterChange: setGlobalFilter,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
})

// <input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)} />

按列过滤使用相同的行模型,通过表头输入框中的 column.getFilterValue()column.setFilterValue() 驱动。模糊匹配功能也可实现,但需要将独立的 @tanstack/match-sorter-utils 工具作为自定义 filterFn 引入。仅在子字符串匹配不满足需求时才考虑使用它。

分页

添加 getPaginationRowModel() 并通过 pageIndex/pageSize 状态驱动分页,然后使用 table.getCanNextPage()table.getCanPreviousPage() 控制”下一页”/“上一页”按钮的可用状态。在 v8 中,行模型的注册顺序不影响结果,因此这些功能可以与排序和过滤在同一个 hook 调用中自由组合。

const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })

const table = useReactTable({
  data,
  columns,
  state: { sorting, globalFilter, pagination },
  onSortingChange: setSorting,
  onGlobalFilterChange: setGlobalFilter,
  onPaginationChange: setPagination,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
})
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
  Previous
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
  Next
</button>

至此,一个完整可用的表格已经搭建完毕:一次 useReactTable 调用,同时支持排序、过滤和分页。

扩展性:memoization 陷阱、虚拟化与自定义单元格

TanStack Table 中最常见的 bug 是无限重渲染循环,其根源在于每次渲染都传入了新的 datacolumns 数组。在组件函数体内声明 const columns = [...]const data = [...] 会在每次渲染时创建新的引用;TanStack Table 检测到引用变化后,会重新计算行模型,触发新一轮渲染,如此循环往复。请将两者都用 useMemo 包裹(或像前面的示例那样在模块作用域中定义),以保持引用稳定。

const columns = useMemo(() => [/* ... */], [])
const data = useMemo(() => fetchedRows, [fetchedRows])

分页还是虚拟化? 当页面大小有限,或数据从服务端按页获取时,使用分页。当你在单个连续滚动视图中持有数千条客户端数据时,使用虚拟化。对于后者,可使用 @tanstack/react-virtualuseVirtualizer hook 对 table.getRowModel().rows 进行处理,只渲染可见行。注意,当前 v3 版本的 hook 名称为 useVirtualizer,而非旧版的 useVirtual

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

const rows = table.getRowModel().rows
const parentRef = useRef<HTMLDivElement>(null)

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 40,
  overscan: 10,
})

随后,在一个由 getTotalSize() 确定尺寸的占位容器内渲染 rowVirtualizer.getVirtualItems(),具体实现方式可参考 TanStack Virtual 文档。这类问题正是会话回放(session replay)擅长暴露的故障场景:一个未经虚拟化的万行表格在本地使用种子数据时感觉尚可,但在生产环境中回放真实用户进行排序或快速滚动的操作时,掉帧和滚动卡顿问题便会浮出水面。

自定义单元格只需配置列的 cell 渲染器即可:返回状态徽章、格式化日期或可编辑输入框,库本身不会干预,因为它从不自行渲染 UI。列固定(pinning)、列宽调整(resizing)和列重排(reordering)也通过各自的状态 API 得到支持。

总结

至此,你已拥有一个在单次 useReactTable 调用中集排序、过滤和分页于一体的表格,以及保持其高性能的两个关键手段:稳定的 data/columns 引用,以及在行数超过数千条时启用虚拟化。当前请基于 v8 进行开发,因为 v9 自 2026 年 6 月起仍处于 beta 阶段;一旦行数超出单页承载范围,即可引入 useVirtualizer

常见问题

为什么我的 TanStack Table 会无限重渲染?

无限重渲染循环几乎总是由于每次渲染都传入了新的 data 或 columns 数组所致。在组件函数体内声明 const columns = [] 或 const data = [] 会在每次渲染时创建新的引用,TanStack Table 检测到引用变化后会重新计算行模型,进而触发新一轮渲染。请将两者都用 useMemo 包裹,或在模块作用域中定义它们,以保持引用稳定。

我应该使用 TanStack Table v8 还是 v9?

生产环境请使用 v8。稳定版为 v8(npm latest 标签对应版本为 8.21.3),而 v9 自 2026 年 6 月起仍处于 beta 阶段,目前仅以预发布版本的形式发布在 npm 的 beta 标签下。v8 支持 React 16.8、17、18 和 19。生产环境中的表格请继续使用 v8,仅在非关键代码中尝试 v9,直至其发布稳定版本。

TanStack Table 应该何时使用分页,何时使用虚拟化?

当页面大小有限,或数据从服务端按页获取时,使用分页,通过 getPaginationRowModel 和 pageIndex/pageSize 状态驱动。当你在单个连续滚动视图中持有数千条客户端数据时,使用虚拟化:通过 @tanstack/react-virtual 的 useVirtualizer 对 table.getRowModel().rows 进行处理,只渲染可见行。两者并非互斥,但在单个滚动区域内的客户端行数超过数千条时,虚拟化才是正确的选择。

TanStack Table 是否提供默认样式或无障碍角色?

不提供。TanStack Table v8 是完全无头的。它不生成任何标记、样式或 role 属性;v8 的重写彻底移除了这些内容,以使核心库能够与任何框架配合使用。你完全掌控整个 DOM,因此语义化标记(如 table、带 scope 属性的 th 以及任何 ARIA 角色)均由你负责。可在你自己的标记上使用 Tailwind、CSS Modules 或 styled components 进行样式设计。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.