关于 web-ui-tanstack-table
面向使用 TanStack Table v8 构建类型安全无头表格的开发者,介绍行模型、表格状态以及客户端和服务端处理方式,不规定标记或样式。
主要功能
- 01类型化列定义
- 02排序、筛选与分页
- 03行选择与展开
- 04列显示与固定
- 05服务端数据处理模式
- 06稳定引用与行标识
适用场景
- 01构建自定义表格标记
- 02将表格状态连接到服务端查询
- 03添加选择和批量操作
- 04保持大型表格响应流畅
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-ui-tanstack-table安装后在 Claude Code 中输入/web-ui-tanstack-table确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/column-pinning.mdcolumn-resizing.mdcolumn-visibility.mdcore.mdexpanding.mdfiltering.mdpagination.mdselection.mdserver-side.mdsorting.mdvirtualization.md详细介绍
TanStack Table 模式
快速指南: TanStack Table v8 只负责表格_逻辑_——它会计算行、单元格和表头组,但完全不负责渲染。你可以使用
createColumnHelper<T>()描述数据,通过导入相应的行模型来启用各项功能,并根据实例返回的内容渲染任意标记。两个事实最容易引发问题:data和columns必须保持引用稳定,否则表格会不断重新计算;而manual*标志用于告知它服务器已经完成了相应工作。
详细资源:
- examples/core.md — 实例设置、列定义、
flexRender - examples/sorting.md — 排序函数、多列排序、
aria-sort - examples/filtering.md — 列筛选、全局筛选、自定义
filterFn - examples/pagination.md — 分页状态和导航控件
- examples/selection.md — 复选框列、不确定状态、批量操作
- examples/expanding.md — 可展开的行和子行
- examples/column-visibility.md — 显示和隐藏列
- examples/column-pinning.md — 固定列及其粘性偏移量
- examples/column-resizing.md — 调整大小的手柄和 CSS 变量技巧
- examples/server-side.md —
manual*标志和rowCount - examples/virtualization.md — 将行模型连接到窗口化列表
- reference.md — 导入、各功能检查清单、反模式及代码
适用路径
排序、筛选和分页发生在哪里,是决定其他所有答案的分岔点。
- 客户端持有所有行。 导入所需功能对应的行模型——
getSortedRowModel、getFilteredRowModel、getPaginationRowModel——由表格完成处理。 请参阅 examples/core.md 和各功能对应的文件。 - 服务器已完成处理。 将
manualSorting、manualFiltering和manualPagination设为true,提供rowCount,并且不导入这些行模型——设置了manual*标志后, 对应的行模型会被忽略,因此同时引入两者只是徒增负担,却会让人误以为该功能正常运行。 请参阅 examples/server-side.md。 - 所有行都已存在,但渲染量太大。 保留客户端行模型,对输出进行窗口化,而不是分页。 请参阅 examples/virtualization.md。
<critical_requirements>
编写 TanStack Table 代码前
让 data 和 columns 保持引用稳定——使用 useMemo 或模块级常量。表格按引用标识进行比较,因此每次渲染都创建新数组,就意味着每次渲染都会产生一组新列;由此引发的更新循环表现为标签页卡死,而不是报错。
为列辅助函数指定类型:createColumnHelper<Row>()。 这样每个访问器键都会根据行类型进行检查,每个单元格的 getValue() 也会根据其读取的字段获得类型,这样重命名字段时就会触发编译错误,而不是生成一列 undefined。
为 accessorFn 指定明确的 id。 字符串 accessorKey 会提供自己的 ID;函数没有可用于推导 ID 的名称,因此没有 id 就无法通过该列进行排序、筛选或控制可见性。
在选择行或重新加载数据时设置 getRowId。 默认标识是数组索引,因此排序、筛选或重新获取数据后,选中状态会悄然转移到当前占据这些位置的行。
只导入用到的行模型。 每个行模型都有独立的入口点;未导入的行模型会被 tree-shaking 移除,这正是这些功能采用这种打包方式的原因。
通过 flexRender 渲染。header 和 cell 都可以是字符串或组件,flexRender 会处理这两种情况——直接读取 columnDef.header 看似可行,但遇到第一个提供函数的列时就会出问题。
</critical_requirements>
自动检测:@tanstack/react-table、useReactTable、createColumnHelper、columnHelper.accessor、columnHelper.display、columnHelper.group、getCoreRowModel、getSortedRowModel、getFilteredRowModel、getPaginationRowModel、getExpandedRowModel、flexRender、ColumnDef、SortingState、ColumnFiltersState、PaginationState、RowSelectionState、VisibilityState、ExpandedState、ColumnPinningState、manualPagination、manualSorting、accessorKey、accessorFn、getRowId
适用范围:
- 定义列——访问器、计算值、显示列、分组表头
- 表格状态:排序、筛选、分页、选择、展开、可见性、固定、尺寸
- 选择客户端还是服务端操作,并接入相应模式
- 读取实例以进行渲染:表头组、可见单元格、行模型
- 保持大型表格响应流畅——记忆化、摇树优化、行输出窗口化
其他部分负责:
- 每个元素和每种样式。此库返回数据结构;示例中的
<table>只是渲染它们的一种方式,结果的外观不属于本技能的关注范围 - 行的来源。服务端模式使用已分页的数组和总数,无论它们通过何种方式获取
- 用作筛选和分页输入的控件——表格需要一个值和一个变更处理函数,至于生成它们的控件则无关紧要
- 窗口化本身。表格会提供一个扁平的行数组;测量视口并渲染适合的部分属于另一项能力,而 examples/virtualization.md 仅展示两者之间的衔接方式
无头设计意味着此库不会强加任何需要你费力应对的观点。没有需要覆盖的主题,没有需要斗争的标记,也没有需要深入访问其内部的组件——实例负责回答问题,而你来决定绘制什么。
因此,**默认情况下什么功能都不会启用。**表格不支持排序并不是故障;它只是没有导入 getSortedRowModel。从组件库转来的人可能会因此困惑,因为在组件库中,功能是通过属性启用的。值得记住的是:当某项功能不起作用时,首先要检查是否提供了相应的行模型,其次要检查是否设置了 manual* 标记,告诉表格不要处理这项功能。
同样的设计也让此库与框架无关——核心部分是纯 TypeScript,React 适配器则很轻薄——这就是为什么其 API 表现为返回数据的函数,而不是负责执行操作的 Hook。
<decision_framework>
应导入哪些行模型
| 功能 | 行模型 | 备注 |
|---|---|---|
| 任何功能 | getCoreRowModel | 始终必需 |
| 排序 | getSortedRowModel | 设置 manualSorting 时省略 |
| 列筛选和全局筛选 | getFilteredRowModel | 设置 manualFiltering 时省略 |
| 分页 | getPaginationRowModel | 设置 manualPagination 时省略 |
| 可展开行、子行 | getExpandedRowModel | |
| 行分组 | getGroupedRowModel | |
| 筛选 UI 的不同取值 | getFacetedRowModel | |
| 列固定、列尺寸 | 无 | 两者都属于核心功能 |
如何声明列
Reading a field directly? → columnHelper.accessor("email")
Reading a nested field? → columnHelper.accessor("address.city") (dot notation)
Deriving a value from the row? → columnHelper.accessor(fn, { id: "…" }) (id is required)
No data at all — buttons, a checkbox?
→ columnHelper.display({ id: "…" })
Heading a set of other columns? → columnHelper.group({ columns: [...] })
关键区别在于:访问器的返回值是排序和筛选的操作对象,因此它应保持为原始值。所有视觉内容都应放在 cell 中。
</decision_framework>
核心模式
模式 1:表格设置
实例由数据、列以及你选择启用的行模型构建。两项输入都会进行记忆化;getRowId 会用稳定标识替换基于索引的标识。
const columnHelper = createColumnHelper<User>();
const columns = useMemo(
() => [
columnHelper.accessor("firstName", { header: "First Name" }),
columnHelper.accessor((row) => `${row.firstName} ${row.lastName}`, {
id: "fullName", // required — a function has no key to derive one from
header: "Full Name",
}),
],
[],
);
const table = useReactTable({
data: useMemo(() => users, [users]),
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
});
完整代码:examples/core.md
模式 2:排序
将状态提升到组件外部,使其可持久化;行模型负责排序。
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
/* … */
state: { sorting },
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
});
columnHelper.accessor("createdAt", { sortingFn: "datetime" });
Date 对象需要使用 sortingFn: "datetime"——默认比较器会将它们视为字符串,因此会按星期名称排序。
完整代码:examples/sorting.md
模式 3:筛选
列筛选和全局筛选是相互独立的状态,各自有独立的处理器,但都由同一个行模型处理。
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
/* … */
state: { columnFilters, globalFilter },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getFilteredRowModel: getFilteredRowModel(),
});
多个列筛选条件会取交集——一行必须满足所有条件。内置功能不支持并集;要实现并集,可以使用全局筛选或自定义 filterFn。
模式 4:分页
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
/* … */
state: { pagination },
onPaginationChange: setPagination,
getPaginationRowModel: getPaginationRowModel(),
});
pageIndex 从零开始计数。大多数 API 从一开始计数,因此转换应放在请求边界处,其他地方都不应转换。
模式 5:行选择
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
/* … */
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowId: (row) => row.id, // the selection is keyed by this
});
选择状态是一个以行 ID 为键的映射,因此如果没有 getRowId,它就会以数组位置为键——表格排序后,每个选择项都会重新指向不同的记录。
模式 6:服务端数据
manual* 标志会告知表格,传入的数组已经过排序、筛选和分页。表格随后只报告状态变化,不执行任何计算。
const table = useReactTable({
data: apiData ?? [],
columns,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
rowCount: totalFromApi, // it cannot count rows it never received
});
模式 7:列可见性
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
email: false, // hidden until asked for
});
columnHelper.accessor("id", { enableHiding: false }); // never offered in the toggle UI
隐藏列会从 getVisibleCells() 中移除,但仍保留在表格中,因此它们的筛选和排序仍会生效。
完整代码:examples/column-visibility.md
模式 8:展开行
const [expanded, setExpanded] = useState<ExpandedState>({});
const table = useReactTable({
/* … */
state: { expanded },
onExpandedChange: setExpanded,
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: () => true, // or a predicate over row.original
});
展开行会交错插入行模型,而不是嵌套其中,因此要渲染详情面板,需要检查 row.getIsExpanded() 并自行输出一个额外元素。
模式 9:可复用的泛型表格
泛型让同一个组件可适用于所有行类型,同时不会丢失调用处的类型推断。
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// render from table.getHeaderGroups() and table.getRowModel()
}
完整代码:examples/core.md
模式 10:列固定
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ["id"],
right: ["actions"],
});
固定列会重新排列列,并报告每列位于哪一侧。它不会设置任何定位样式——position: sticky、偏移量和不透明背景都需要你自行处理;背景用于避免滚动内容透显出来。
完整代码:examples/column-pinning.md
模式 11:列宽调整
const table = useReactTable({
/* … */
enableColumnResizing: true,
columnResizeMode: "onChange", // or "onEnd"
});
"onChange" 会在拖动期间持续更新,并使所有单元格随之重新渲染,因此需要使用 CSS 变量技术和经过记忆化的表格主体来维持帧率。"onEnd" 会在松开时提交,不需要这两项技术。
完整代码:examples/column-resizing.md
<red_flags>
危险信号
运行时会出错:
columns或data在行内构建——每次渲染都会生成新的数组标识,导致表格陷入更新循环,直到标签页停止响应accessorFn未设置id——列没有标识符,构建实例时会抛出错误- 使用客户端行模型,同时设置对应的
manual*标志——标志优先级更高,因此行模型不起作用,但看起来像是功能已接入 - 设置
manualPagination,却没有rowCount或pageCount——表格无法从单页推导出总页数,因此翻页会停在第 1 页 - 选择功能未设置
getRowId——以数组索引作为键,因此排序或重新获取数据后,选中状态会对应到不同记录 - 在访问器中返回 JSX——排序和筛选会比较 React 元素而非值,导致列的排序毫无意义
- 直接读取
columnDef.header或columnDef.cell,而不是通过flexRender——字符串表头可正常工作,其他列则都会渲染原始函数
容易令人意外的行为:
pageIndex从 0 开始,而大多数 API 从 1 开始autoResetPageIndex默认为true,因此任何数据变更都会跳回第 1 页——在服务端模式下通常不正确,因为新数据到达_本身_就是页面变更- 列筛选条件按 AND 组合;没有内置的 OR
- 列固定和列宽调整不需要行模型,这一点与其他所有功能都不同
- 固定操作发生在列排序和分组之前,因此两者有冲突时固定操作优先
- 调整大小的拖动手柄需要同时使用
getResizeHandler()提供的onMouseDown和onTouchStart,否则在触屏设备上不起作用 - 隐藏的列仍会参与筛选和排序;可见性只影响
getVisibleCells(),不会影响上游处理