首页 / TanStack 生态入门教程 / 分页与分组

TanStack 生态入门教程

分页与分组

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

TanStackTanStack 生态入门教程TanStack Table分页Pagination分组Grouping聚合展开

29. 分页与分组

本节目标:给表格加上分页和分组功能。学会用 getPaginatedRowModel 实现翻页、管理分页状态、用 getGroupedRowModel 按字段分组并做聚合(Aggregation)计算、用 expanding 控制分组行展开收起。学完你能处理大量数据的翻页展示,也能做按城市分组的汇总统计。

29.1 分页:getPaginatedRowModel

数据多了不能一股脑全渲染,得翻页。分页是行模型流水线的最后一道工序:

核心 -> 排序 -> 过滤 -> 分组 -> 展开 -> 分页

分页在最后,意味着它操作的是排好序、滤好、分好组之后的行。

29.1.1 开启分页

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

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getPaginationRowModel: getPaginationRowModel(), // 分页行模型
  state: { pagination },
  onPaginationChange: setPagination,
})

和排序过滤一样,分页也需要引入行模型、管理状态。

29.1.2 分页状态

分页状态包含当前页和每页条数:

import { useState } from 'react'
import type { PaginationState } from '@tanstack/react-table'

const [pagination, setPagination] = useState<PaginationState>({
  pageIndex: 0, // 当前页索引,从 0 开始
  pageSize: 10,  // 每页显示多少条
})

pageIndex 从 0 开始计数,第一页是 0。

29.1.3 分页 UI

表格实例提供了丰富的分页方法,帮你快速搭翻页 UI:

function PaginatedTable() {
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  })

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    state: { pagination },
    onPaginationChange: setPagination,
  })

  return (
    <div>
      <table>{/* 渲染表格内容,和之前一样 */}</table>

      {/* 分页控制 */}
      <div className="flex items-center gap-2 mt-4">
        <button
          onClick={() => table.setPageIndex(0)}
          disabled={!table.getCanPreviousPage()}
        >
          首页
        </button>
        <button
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          上一页
        </button>
        <span>
          第 {table.getState().pagination.pageIndex + 1} 页 / 共{' '}
          {table.getPageCount()} 页
        </span>
        <button
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          下一页
        </button>
        <button
          onClick={() => table.setPageIndex(table.getPageCount() - 1)}
          disabled={!table.getCanNextPage()}
        >
          末页
        </button>

        {/* 每页条数选择 */}
        <select
          value={table.getState().pagination.pageSize}
          onChange={(e) => table.setPageSize(Number(e.target.value))}
        >
          {[10, 20, 50, 100].map((size) => (
            <option key={size} value={size}>每页 {size} 条</option>
          ))}
        </select>
      </div>
    </div>
  )
}

常用分页方法一览:

方法作用
table.previousPage()上一页
table.nextPage()下一页
table.setPageIndex(n)跳到第 n 页
table.setPageSize(n)设置每页条数
table.getCanPreviousPage()能不能往前往(第一页时 false)
table.getCanNextPage()能不能往后翻(最后一页时 false)
table.getPageCount()总页数
table.getState().pagination当前分页状态
Tip

pageSize 时,表格会自动调整页码。比如原来在第 3 页(每页 10 条),改成每页 50 条后可能就变成第 1 页了,因为总页数变了。

29.1.4 手动分页(服务端分页)

数据量大时,服务端分页更合理。设 manualPagination: true,表格不在前端分页,你拿分页状态去请求接口:

const table = useReactTable({
  data,            // 只有当前页的数据
  columns,
  getCoreRowModel: getCoreRowModel(),
  manualPagination: true, // 手动分页
  rowCount: totalCount,   // 告诉表格总共有多少行(用于算总页数)
  state: { pagination },
  onPaginationChange: setPagination,
})

// 分页状态变化时请求新数据
useEffect(() => {
  fetchData({
    page: pagination.pageIndex,
    size: pagination.pageSize,
  })
}, [pagination])
Warning

手动分页时必须传 rowCount,否则 table.getPageCount() 算不出总页数,翻页按钮的禁用状态也不对。rowCount 是数据总条数,不是当前页条数。

29.2 分组:getGroupedRowModel

分组(Grouping)是按某个字段的值把行归类。比如按城市分组,北京的行放一起、上海的放一起。常配合聚合统计使用。

29.2.1 开启分组

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

const [grouping, setGrouping] = useState<GroupingState>([])
const [expanded, setExpanded] = useState<ExpandedState>({})

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getGroupedRowModel: getGroupedRowModel(), // 分组行模型
  getExpandedRowModel: getExpandedRowModel(), // 展开行模型(分组需要)
  state: { grouping, expanded },
  onGroupingChange: setGrouping,
  onExpandedChange: setExpanded,
})

分组通常要配合展开使用—分组行默认是收起的,点一下展开看组内明细。所以 getExpandedRowModel 也要传。

29.2.2 分组状态

分组状态是个数组,每项是一个列 id,表示按哪些列分组:

// 按 city 列分组
const [grouping, setGrouping] = useState<GroupingState>(['city'])

// 也可以多列分组:先按 city 再按 status
// ['city', 'status']

动态控制分组—让用户点列头来切换分组:

// 切换某列的分组状态
table.getColumn('city')?.toggleGrouping()

29.2.3 分组列的渲染

被分组的列,数据会被「合并」成一个分组行。分组行不显示原始数据,而是显示组标识。比如按城市分组,分组行显示”北京 (3)“这种。

需要在列定义里处理分组时的显示:

const columns = [
  columnHelper.accessor('city', {
    header: '城市',
    // 分组时显示组名和数量
    cell: ({ row }) => {
      if (row.getIsGrouped()) {
        return (
          <button
            onClick={row.getToggleExpandedHandler()}
            className="font-bold"
          >
            {row.getValue('city')} ({row.subRows.length} 条)
          </button>
        )
      }
      return row.getValue('city')
    },
  }),
  columnHelper.accessor('name', {
    header: '姓名',
    // 被分组的列不需要单独显示
    cell: ({ row }) => (row.getIsGrouped() ? null : row.getValue('name')),
  }),
  columnHelper.accessor('age', {
    header: '年龄',
    cell: ({ row }) => (row.getIsGrouped() ? null : row.getValue('age')),
  }),
]

三个关键方法:

  • row.getIsGrouped() — 这行是不是分组行。
  • row.getToggleExpandedHandler() — 切换展开/收起的处理器。
  • row.subRows — 分组行下的子行。
Note

分组行的结构是树形的。分组行下面挂着子行(row.subRows)。如果多列分组,子行也可能是分组行,形成多层嵌套。

29.2.4 展开状态

展开状态是个对象,key 是行 id,value 是 true/false

// expanded 的结构
{
  'city:北京': true,  // 北京组展开了
  'city:上海': false, // 上海组收起了
}

row.getIsExpanded() 检查某行是否展开,用 row.toggleExpanded() 手动切换。

29.2.5 占位列

分组后,被分组的列「吸收」了其他列的信息。非分组的列在分组行上会显示空。可以用 aggregated 模式让这些列显示聚合值:

columnHelper.accessor('age', {
  header: '年龄',
  // 普通行显示原始值
  cell: ({ row }) => {
    if (row.getIsGrouped()) return null // 分组行不显示
    return row.getValue('age')
  },
  // 聚合:分组行上显示平均年龄
  aggregationFn: 'mean',
  aggregatedCell: ({ getValue }) => (
    <span>平均: {getValue()?.toFixed(1)}</span>
  ),
})

29.3 聚合(Aggregation)

聚合是对分组内的数据做统计计算—求和、平均、最大、最小、计数等。

29.3.1 内置聚合函数

在列定义里用 aggregationFn 指定聚合方式:

const columns = [
  columnHelper.accessor('age', {
    header: '年龄',
    aggregationFn: 'mean', // 平均值
  }),
  columnHelper.accessor('salary', {
    header: '薪资',
    aggregationFn: 'sum', // 求和
  }),
  columnHelper.accessor('name', {
    header: '姓名',
    aggregationFn: 'count', // 计数
  }),
]

内置聚合函数:

函数作用返回值示例
sum求和薪资总和 120000
min最小值最小年龄 22
max最大值最大年龄 34
mean平均值平均年龄 28.5
median中位数年龄中位数 28
count计数人数 5

29.3.2 自定义聚合函数

内置的不够用时,自己写:

columnHelper.accessor('age', {
  header: '年龄',
  aggregationFn: (columnId, leafRows, allRows) => {
    // leafRows: 这组里的叶子行(实际数据行)
    // 拿到所有年龄值,算个范围
    const ages = leafRows.map((row) => row.getValue(columnId) as number)
    return `${Math.min(...ages)}-${Math.max(...ages)}`
  },
  aggregatedCell: ({ getValue }) => (
    <span>年龄范围: {getValue()}</span>
  ),
})

自定义聚合函数接收三个参数:列 id、叶子行数组、所有行数组。返回的值会存在分组行的该列上,通过 getValue() 取到。

29.3.3 显示聚合值

aggregatedCell 是专门给分组行渲染聚合值的属性:

columnHelper.accessor('salary', {
  header: '薪资',
  aggregationFn: 'sum',
  // 普通行:显示个人薪资
  cell: ({ row }) => {
    if (row.getIsGrouped()) return null
    return `¥${row.getValue('salary').toLocaleString()}`
  },
  // 分组行:显示聚合后的薪资
  aggregatedCell: ({ getValue }) => (
    <span className="font-bold text-blue-600">
      合计: ¥{getValue()?.toLocaleString()}
    </span>
  ),
})

三种渲染场景:

  • cell — 普通数据行,显示原始值。
  • aggregatedCell — 分组行,显示聚合值。
  • 分组列自身 — 用 row.getIsGrouped() 判断后自定义。

29.4 完整的分页分组示例

import { useState } from 'react'
import {
  useReactTable,
  getCoreRowModel,
  getPaginationRowModel,
  getGroupedRowModel,
  getExpandedRowModel,
  flexRender,
  createColumnHelper,
} from '@tanstack/react-table'
import type { PaginationState, GroupingState, ExpandedState } from '@tanstack/react-table'

type Person = { id: number; name: string; age: number; city: string; salary: number }

const data: Person[] = [
  { id: 1, name: '张三', age: 28, city: '北京', salary: 15000 },
  { id: 2, name: '李四', age: 34, city: '北京', salary: 22000 },
  { id: 3, name: '王五', age: 22, city: '上海', salary: 12000 },
  { id: 4, name: '赵六', age: 30, city: '上海', salary: 18000 },
  { id: 5, name: '孙七', age: 26, city: '广州', salary: 14000 },
]

const columnHelper = createColumnHelper<Person>()

const columns = [
  columnHelper.accessor('city', {
    header: '城市',
    cell: ({ row }) => {
      if (row.getIsGrouped()) {
        return (
          <button onClick={row.getToggleExpandedHandler()} className="font-bold">
            {row.getValue('city')} ({row.subRows.length} 人)
          </button>
        )
      }
      return row.getValue('city')
    },
  }),
  columnHelper.accessor('name', {
    header: '姓名',
    cell: ({ row }) => (row.getIsGrouped() ? null : row.getValue('name')),
  }),
  columnHelper.accessor('age', {
    header: '年龄',
    aggregationFn: 'mean',
    cell: ({ row }) => {
      if (row.getIsGrouped()) return null
      return row.getValue('age')
    },
    aggregatedCell: ({ getValue }) => (
      <span>平均 {Number(getValue()).toFixed(1)}</span>
    ),
  }),
  columnHelper.accessor('salary', {
    header: '薪资',
    aggregationFn: 'sum',
    cell: ({ row }) => {
      if (row.getIsGrouped()) return null
      return `¥${row.getValue('salary').toLocaleString()}`
    },
    aggregatedCell: ({ getValue }) => (
      <span className="text-blue-600 font-bold">
        合计 ¥{Number(getValue()).toLocaleString()}
      </span>
    ),
  }),
]

function GroupedTable() {
  const [grouping, setGrouping] = useState<GroupingState>(['city'])
  const [expanded, setExpanded] = useState<ExpandedState>({})
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  })

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getGroupedRowModel: getGroupedRowModel(),
    getExpandedRowModel: getExpandedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    state: { grouping, expanded, pagination },
    onGroupingChange: setGrouping,
    onExpandedChange: setExpanded,
    onPaginationChange: setPagination,
  })

  return (
    <div>
      <table className="border-collapse w-full">
        <thead>
          {table.getHeaderGroups().map((hg) => (
            <tr key={hg.id}>
              {hg.headers.map((h) => (
                <th key={h.id} className="border px-3 py-2 bg-gray-50 text-left">
                  {flexRender(h.column.columnDef.header, h.getContext())}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className={row.getIsGrouped() ? 'bg-gray-100' : ''}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="border px-3 py-2">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      <div className="flex gap-2 mt-3">
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>上一页</button>
        <span>第 {table.getState().pagination.pageIndex + 1} 页</span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>下一页</button>
      </div>
    </div>
  )
}

29.5 常见坑

坑一:分组了但展开不了。 忘了传 getExpandedRowModel,分组行点击没反应。分组和展开是配套的,缺一不可。

坑二:分组行显示空白。 被分组的列没处理 row.getIsGrouped() 的情况,普通渲染逻辑对分组行不适用。要在 cell 函数里判断是不是分组行。

坑三:聚合函数对非数字列报错。 summean 这些函数要求数据是数字。对字符串列用 sum 会得到 NaN。非数字列用 count 或自定义聚合函数。

坑四:手动分页忘传 rowCount 不传的话总页数算不出来,nextPage 的禁用状态永远是 false(以为还有下一页),用户点了却没数据。

坑五:分组和分页同时用导致每页只有分组行。 分组行占了一页,展开的内容看不到了。分页在分组之后执行,大分组可能跨页。建议分组时把 pageSize 设大点,或者分组模式下不用分页。

29.6 小结

这一章讲了分页和分组:

  • 分页:传 getPaginationRowModel,管理 pagination 状态(pageIndex + pageSize),用 previousPage / nextPage / setPageIndex 控制翻页。
  • 手动分页manualPagination: true + rowCount,服务端分页只管状态。
  • 分组:传 getGroupedRowModel + getExpandedRowModel,管理 groupingexpanded 状态。
  • 聚合:列定义里设 aggregationFn,用 aggregatedCell 渲染聚合值。内置 sum/mean/min/max/count 等。
  • 展开row.getIsGrouped() 判断分组行,row.getToggleExpandedHandler() 切换展开。

下一章讲列固定和行选择,让你的表格支持冻结列、勾选行等高级交互。