首页 / TanStack 生态入门教程 / 表格状态与虚拟化

TanStack 生态入门教程

表格状态与虚拟化

本教程共 38 篇 · 第 31 篇 · 更新于 2026-07-27 · 约 10 分钟阅读

TanStackTanStack 生态入门教程TanStack Table表格状态Table State虚拟化VirtualizationCustom Features

31. 表格状态与虚拟化

本节目标:理解 TanStack Table 的状态管理体系(受控 vs 非受控、初始状态、状态持久化),学会用 TanStack Virtual 给表格做虚拟化渲染处理万行数据,顺带了解自定义特性扩展。学完你能把表格状态同步到 URL,也能渲染上万行数据不卡。

31.1 表格状态全景

前面几章你已经接触了不少状态:sortingcolumnFiltersglobalFilterpaginationgroupingexpandedcolumnPinningrowSelectioncolumnSizing

这些状态有两种管理模式:受控(Controlled)非受控(Uncontrolled)

打个比方:受控就像你亲自开车,方向盘在你手里(状态在组件的 useState 里);非受控就像自动驾驶,车子自己管(状态在表格实例内部)。

31.1.1 非受控模式

不给表格传 stateonXxxChange,表格自己管状态:

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  // 没传 state.sorting 和 onSortingChange
  // 表格内部自己管理 sorting 状态
})

非受控模式简单省事,但你在外面读不到当前状态。适合不需要和外部交互的简单场景。

31.1.2 受控模式

把状态提到组件里管,传给表格:

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

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  state: { sorting },          // 传当前状态
  onSortingChange: setSorting, // 状态变化时回调
})

受控模式你完全掌控状态,能做这些事:

  • 同步到 URL(刷新页面不丢排序状态)
  • 传给其他组件(比如把当前过滤条件显示在面包屑上)
  • 持久化到 localStorage
  • 跨组件共享状态
Tip

实际项目里推荐用受控模式。初始开发时用非受控省事,但后面要加状态持久化、URL 同步时还得改成受控。不如一开始就受控。

31.1.3 混合模式

不是所有状态都要受控。你可以只受控需要外部交互的状态,其余的非受控:

// 只受控 sorting(要同步到 URL),其余的非受控
const [sorting, setSorting] = useState<SortingState>([])

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  state: { sorting }, // 只传 sorting
  onSortingChange: setSorting,
  // 没传 columnFilters、pagination 等的状态
  // 这些状态表格自己管
})

31.2 初始状态(InitialState)

不想受控但又想设默认值?用 initialState

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  initialState: {
    sorting: [{ id: 'name', desc: false }], // 默认按 name 升序
    pagination: {
      pageIndex: 0,
      pageSize: 20, // 默认每页 20 条
    },
    columnVisibility: { age: false }, // age 列默认隐藏
  },
})

initialState 只在表格初始化时生效一次,之后状态由表格自己管(非受控)或由你管(受控)。

Warning

initialStatestate 不能同时设同一个状态。比如设了 state: { sorting } 就不能再设 initialState: { sorting: ... },会冲突。受控状态用 useState 的初始值设默认值,非受控状态用 initialState

31.3 状态同步到 URL

常见需求:排序、过滤、分页状态同步到 URL,刷新页面或分享链接时能恢复。

31.3.1 读取 URL 初始化状态

import { useSearchParams } from 'react-router-dom'

function UrlStateTable() {
  const [searchParams, setSearchParams] = useSearchParams()

  // 从 URL 读排序状态
  const sortParam = searchParams.get('sort') // "name-desc" 或 null
  const initialSorting: SortingState = sortParam
    ? [{ id: sortParam.split('-')[0], desc: sortParam.split('-')[1] === 'desc' }]
    : []

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

  // 排序变化时更新 URL
  const handleSortingChange = (updater) => {
    const newSorting = typeof updater === 'function' ? updater(sorting) : updater
    setSorting(newSorting)

    if (newSorting.length > 0) {
      const { id, desc } = newSorting[0]
      setSearchParams({ sort: `${id}-${desc ? 'desc' : 'asc'}` })
    } else {
      // 清除排序参数
      searchParams.delete('sort')
      setSearchParams(searchParams)
    }
  }

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    state: { sorting },
    onSortingChange: handleSortingChange,
  })

  // ... 渲染
}
Note

onSortingChange 的参数可能是新值也可能是更新函数。处理时要判断 typeof updater === 'function',是函数就调用它拿到新值。这是 TanStack Table 状态回调的统一模式。

31.4 状态重置

表格实例提供了 resetXxx 方法,把某个状态重置到初始值:

// 重置排序
table.resetSorting()
// 重置分页
table.resetPagination()
// 重置行选择
table.resetRowSelection()
// 重置列固定
table.resetColumnPinning()
// 重置所有状态
table.reset()

resetXxx 默认重置到 initialState 里的值,也可以传 false 参数重置到默认值而非初始值:table.resetSorting(false)

31.5 自定义特性(Custom Features)

TanStack Table v8 支持通过 createTable 扩展自定义特性。这是高级用法,了解即可。

import { createTable, getCoreRowModel } from '@tanstack/table-core'

// 定义自定义特性
function myCustomFeature<TData extends RowData>() {
  return (instance: Table<TData>): Table<TData> => {
    return {
      ...instance,
      myCustomMethod: () => {
        console.log('自定义方法', instance.getRowModel().rows.length)
      },
    }
  }
}

// 使用自定义特性
const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  _features: [myCustomFeature],
})

table.myCustomMethod?.()
Tip

自定义特性主要用于二次封装表格库的场景。普通使用不需要,了解有这么个能力就行。

31.6 与 Virtual 集成:大数据表格

前面几章的表格,不管多少行数据全部渲染 DOM。100 行没问题,10000 行就会卡—浏览器渲染上万个 DOM 节点很吃力。

解决方案是虚拟化(Virtualization):只渲染可视区域内的行,滚动时动态替换。TanStack 生态有自己的虚拟化库 @tanstack/react-virtual,和 Table 天然集成。

31.6.1 安装

npm install @tanstack/react-virtual

31.6.2 基本虚拟化表格

核心思路:用 useVirtualizer 算出哪些行在可视区域内,只渲染这些行。

import { useVirtualizer } from '@tanstack/react-virtual'
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'
import { useRef } from 'react'

function VirtualizedTable({ data, columns }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    // 大数据场景通常不用前端分页,直接虚拟化
  })

  // 滚动容器
  const parentRef = useRef<HTMLDivElement>(null)

  // 行虚拟化器
  const rowVirtualizer = useVirtualizer({
    count: table.getRowModel().rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35, // 每行预估高度
    overscan: 10, // 上下额外渲染的行数
  })

  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      {/* 撑开滚动高度的容器 */}
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {/* 只渲染可视区域的行 */}
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const row = table.getRowModel().rows[virtualRow.index]
          return (
            <div
              key={row.id}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: virtualRow.size,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {/* 渲染一行 */}
              <div style={{ display: 'flex' }}>
                {row.getVisibleCells().map((cell) => (
                  <div
                    key={cell.id}
                    style={{ width: cell.column.getSize(), padding: '8px' }}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </div>
                ))}
              </div>
            </div>
          )
        })}
      </div>
    </div>
  )
}

关键步骤:

  1. 创建滚动容器(parentRef),设固定高度和 overflow: auto
  2. useVirtualizer 创建行虚拟化器,传入总行数、滚动元素、预估行高。
  3. 内层容器高度设为 getTotalSize()(全部行的总高度),撑开滚动条。
  4. 只遍历 getVirtualItems()(可视区域的行),用 position: absolute + translateY 定位。
Note

虚拟化表格通常用 <div> 替代 <table>,因为原生 <table> 的布局规则和绝对定位冲突。用 flex 布局模拟表格结构更灵活。

31.6.3 虚拟化 + 排序过滤

虚拟化不影响排序和过滤,它们在行模型层面工作。虚拟化只管「渲染哪些行」,排序过滤管「行的顺序和数量」:

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

// 虚拟化器用的是 table.getRowModel().rows.length
// 排序过滤后行数变了,虚拟化器自动适应
const rowVirtualizer = useVirtualizer({
  count: table.getRowModel().rows.length, // 过滤后的行数
  getScrollElement: () => parentRef.current,
  estimateSize: () => 35,
})

31.6.4 动态行高

行高不固定时(内容多少不一),用 measureElement 动态测量:

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 35,
  measureElement: (element) => element.getBoundingClientRect().height,
})

// 渲染时把 measureElement 绑到 ref 上
<div
  ref={rowVirtualizer.measureElement}
  data-index={virtualRow.index}
  style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}
>
  {/* 行内容 */}
</div>

虚拟化器会自动测量每个渲染过的行的实际高度,下次渲染时用实际值替代预估值。

Warning

动态行高有性能开销。每行首次渲染时都会触发一次 measureElement,可能导致滚动时短暂闪烁。行高差异不大时建议用固定预估高度,关闭动态测量。

31.7 虚拟化表格的完整结构

一个功能较全的虚拟化表格通常长这样:

function BigDataTable({ data, columns }) {
  const parentRef = useRef<HTMLDivElement>(null)

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

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

  const { rows } = table.getRowModel()

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

  return (
    <div>
      {/* 搜索框 */}
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="搜索..."
      />

      {/* 表头(固定不滚动) */}
      <div style={{ display: 'flex' }}>
        {table.getHeaderGroups()[0]?.headers.map((header) => (
          <div
            key={header.id}
            onClick={header.column.getToggleSortingHandler()}
            style={{ width: header.column.getSize(), padding: '8px', fontWeight: 'bold' }}
          >
            {flexRender(header.column.columnDef.header, header.getContext())}
            {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
          </div>
        ))}
      </div>

      {/* 可滚动的表体(虚拟化) */}
      <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
        <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = rows[virtualRow.index]
            return (
              <div
                key={row.id}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: virtualRow.size,
                  transform: `translateY(${virtualRow.start}px)`,
                  display: 'flex',
                }}
              >
                {row.getVisibleCells().map((cell) => (
                  <div
                    key={cell.id}
                    style={{ width: cell.column.getSize(), padding: '8px' }}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </div>
                ))}
              </div>
            )
          })}
        </div>
      </div>

      <div>共 {rows.length} 行</div>
    </div>
  )
}

31.8 常见坑

坑一:受控和非受控混用同一个状态。 同时传了 state: { sorting }initialState: { sorting: [...] },后者不生效。受控状态用 useState 初始值设默认。

坑二:状态回调没处理 updater 函数。 onSortingChange 的参数可能是值也可能是函数。直接 setSorting(updater) 对函数类型的 updater 也能工作,因为 React 的 setState 本身支持函数更新器。但如果你在回调里做额外操作(如更新 URL),就得先判断类型。

坑三:虚拟化表格用 <table> 渲染。 原生表格的布局规则和绝对定位不兼容。虚拟化表格用 <div> + flex 布局,不要用 <table>/<tr>/<td>

坑四:虚拟化后行高不准导致跳动。 预估高度和实际差太多,滚动时行会跳动。尽量让 estimateSize 接近实际值,或用 measureElement 动态测量。

坑五:排序/过滤后虚拟化没更新。 虚拟化器的 count 用了旧的 rows.length。确保 count: table.getRowModel().rows.length 每次都能拿到最新的行数,虚拟化器会自动重新计算。

31.9 小结

这一章是 Table 部分的收尾:

  • 状态管理:受控(传 state + onXxxChange)和非受控(表格自己管),推荐受控。initialState 设非受控状态的默认值。
  • 状态持久化:受控状态下,把状态同步到 URL 或 localStorage,刷新不丢状态。
  • 状态重置resetXxx() 方法重置到初始值。
  • 自定义特性:通过 _features 扩展表格实例方法,高级用法。
  • 虚拟化集成:用 useVirtualizer 只渲染可视行,配合 position: absolute + translateY 定位。万行数据也能流畅滚动。
  • 虚拟化注意事项:用 <div> 不用 <table>count 用最新行数,行高用 estimateSizemeasureElement

Table 六章到此结束。下一章开始讲 TanStack Form,从表单状态和 Field API 入手。