---
id: table.server-grid-state
kind: contract
product: table
framework: react
locale: zh-CN
revision: 1
sourceCheckedOn: "2026-08-03"
versionRange: "Table ^8 / Query ^5"
verifiedAgainst: "@tanstack/react-table@8.21.3 + @tanstack/react-query@5.101.4"
contentModel: 2
packages:
  - "@tanstack/react-table"
  - "@tanstack/react-query"
tasks:
  - "server-side-table"
  - "controlled-sorting"
  - "manual-pagination"
  - "query-key"
sourceRefs:
  - "https://tanstack.com/table/latest/docs/framework/react/guide/table-state"
  - "https://tanstack.com/table/latest/docs/guide/sorting"
  - "https://tanstack.com/table/latest/docs/guide/pagination"
---

# 协调服务端表格的排序与分页状态

> 让 Table 管视图状态、Query 管远程数据，并用 manualSorting、manualPagination 与 rowCount 避免只处理当前页。

## 执行契约

- **适用场景:** 表格只持有服务端返回的一页数据，排序与分页必须进入 URL、Query Key 和 API 请求。
- **不要使用:** 数据量小且完整存在客户端时，优先使用内置排序与分页行模型，减少远程状态复杂度。
- **前置检查:** 定义可排序列白名单、零基 pageIndex、pageSize 上限、总 rowCount、稳定 Query Key，以及 URL 与 Table 状态的唯一所有者。
- **验证结果:** 验证排序、翻页、刷新、后退与无结果页；每次状态变化只产生一条正确请求，rowCount 驱动的末页按钮准确禁用。
- **失败模式:** 排序只影响当前页说明误用了 getSortedRowModel；状态完全不动通常是传了 onSortingChange 或 onPaginationChange，却漏掉对应 state 值。
- **安全边界:** 列 id、排序方向、页码和 pageSize 都是外部输入；服务端必须白名单映射列、限制范围、独立鉴权，禁止把列 id 直接拼入 SQL。

## 先确定状态所有权

Table 负责排序和分页的视图模型，Query Key 表达远程结果身份，API 执行全数据集上的排序与分页。若状态需要分享和前进后退，再由 Router Search Params 成为上层唯一来源；不要同时在 URL 与 useState 各保存一份可漂移副本。

## 控制状态必须成对连接

每个 onXChange 都必须对应 state.x，否则该状态会冻结。服务端模式不提供客户端排序或分页行模型；manualSorting 与 manualPagination 表示传入 data 已由服务端处理。rowCount 让 Table 根据 pageSize 推导页数。

File: `src/features/issues/useIssueGrid.ts`

```tsx
import { useQuery } from '@tanstack/react-query'
import {
  getCoreRowModel, type PaginationState, type SortingState, useReactTable,
} from '@tanstack/react-table'
import { useState } from 'react'

const emptyIssues: Issue[] = []

export function useIssueGrid() {
  const [sorting, setSorting] = useState<SortingState>([])
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 20 })
  const result = useQuery({
    queryKey: ['issues', 'grid', { sorting, pagination }] as const,
    queryFn: () => fetchIssuePage({ sorting, pagination }),
  })
  const table = useReactTable({
    columns: issueColumns, data: result.data?.items ?? emptyIssues,
    getCoreRowModel: getCoreRowModel(), getRowId: (issue) => issue.id,
    manualSorting: true, manualPagination: true, rowCount: result.data?.rowCount ?? 0,
    state: { sorting, pagination }, onSortingChange: setSorting,
    onPaginationChange: setPagination,
  })
  return { result, table }
}
```

## 跨页规则必须保持一致

不要把服务端分页与 getSortedRowModel 混用，否则只会排序当前页。排序或筛选变化后应回到第一页；若 URL 是所有者，就在一次导航中同时更新排序和 pageIndex。Query Key 必须包含影响响应的全部状态，但不要包含每次渲染都变化的无关对象。

## 服务端映射列而不是执行列 id

客户端列 id 只是外部字符串。服务端把允许的 id 映射到固定数据库字段，限制 pageSize 和 pageIndex，拒绝未知排序方向，并在查询前执行资源级授权。rowCount 也必须来自同一授权范围。

## Official sources

- https://tanstack.com/table/latest/docs/framework/react/guide/table-state
- https://tanstack.com/table/latest/docs/guide/sorting
- https://tanstack.com/table/latest/docs/guide/pagination
