分页与分组
本教程共 38 篇 · 第 29 篇 · 更新于 2026-07-27 · 约 11 分钟阅读
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 函数里判断是不是分组行。
坑三:聚合函数对非数字列报错。 sum、mean 这些函数要求数据是数字。对字符串列用 sum 会得到 NaN。非数字列用 count 或自定义聚合函数。
坑四:手动分页忘传 rowCount。 不传的话总页数算不出来,nextPage 的禁用状态永远是 false(以为还有下一页),用户点了却没数据。
坑五:分组和分页同时用导致每页只有分组行。 分组行占了一页,展开的内容看不到了。分页在分组之后执行,大分组可能跨页。建议分组时把 pageSize 设大点,或者分组模式下不用分页。
29.6 小结
这一章讲了分页和分组:
- 分页:传
getPaginationRowModel,管理pagination状态(pageIndex+pageSize),用previousPage/nextPage/setPageIndex控制翻页。 - 手动分页:
manualPagination: true+rowCount,服务端分页只管状态。 - 分组:传
getGroupedRowModel+getExpandedRowModel,管理grouping和expanded状态。 - 聚合:列定义里设
aggregationFn,用aggregatedCell渲染聚合值。内置 sum/mean/min/max/count 等。 - 展开:
row.getIsGrouped()判断分组行,row.getToggleExpandedHandler()切换展开。
下一章讲列固定和行选择,让你的表格支持冻结列、勾选行等高级交互。