TanStack 家族条目式字典:Query v5 / Table v9 / Router / Form。每条给语义、示例、边界与坑。
1. useQuery – 服务端状态基础
描述
声明式取数:queryKey 是身份(键变即重新请求),queryFn 是取数函数。v5 用 isPending 替代 isLoading,error 类型为 Error|null。
语法和示例
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: ['posts', { page }],
queryFn: () => fetch(`/api/posts?page=${page}`)
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
staleTime: 60_000, // 新鲜期:期内不重取
gcTime: 5 * 60_000, // 缓存保留期(v4 叫 cacheTime)
retry: 2,
enabled: !!userId, // 条件请求
});
// 只订阅切片(减少重渲染)
useQuery({ ...opts, select: (d) => d.items });注意事项
- queryKey 里的对象是结构比较(键顺序无关),但函数/类实例不参与比较
- queryFn 抛错才会进 isError;返回 4xx 响应不会自动抛(要自己 if (!r.ok) throw)
- staleTime 默认为 0,意味着窗口聚焦、组件挂载都会重取——多数场景该设 30-60 秒
- gcTime 小于 staleTime 会导致数据被回收后重新请求
- queryKey 数组里避免放不稳定的值(如每次新建的对象字面量内容变化)
2. useMutation – 变更与缓存失效
描述
写操作封装:mutationFn 执行、onSuccess 失效相关查询、onError 回滚。配套乐观更新是 v5 的核心能力。
语法和示例
const qc = useQueryClient();
const create = useMutation({
mutationFn: (body: NewPost) => fetch('/api/posts', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}).then(r => r.json()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['posts'] });
},
onError: (err) => toast(err.message),
});
create.mutate(payload);
create.mutateAsync(payload); // 返回 Promise 便于 await注意事项
- invalidateQueries 只是标记过期并触发在用的查询重取,不会立即拿新数据
- onSuccess 里拿不到最新的列表数据——需要旧值用 onMutate + getQueryData
- mutationFn 抛错才会进 onError;返回错误状态码要自己 throw
- mutate 与 mutateAsync 的区别是后者返回 Promise(try/catch 可用)
- 并发多次 mutate 时 onSuccess 可能乱序,需要顺序用 mutateAsync 串行
3. 乐观更新完整套路
描述
onMutate 先改本地缓存并快照旧值 → onError 回滚 → onSettled 无论成败都失效重取。
语法和示例
const update = useMutation({
mutationFn: (next: Todo) => patchTodo(next),
onMutate: async (next) => {
await qc.cancelQueries({ queryKey: ['todos'] }); // 取消在途请求
const prev = qc.getQueryData<Todo[]>(['todos']);
qc.setQueryData<Todo[]>(['todos'], (old) =>
(old ?? []).map(t => t.id === next.id ? next : t));
return { prev }; // 上下文传给回调
},
onError: (_err, _next, ctx) => {
qc.setQueryData(['todos'], ctx?.prev); // 回滚
},
onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});注意事项
- 必须先 cancelQueries,否则在途请求返回会覆盖乐观更新
- onMutate 返回的上下文只能传给同一 mutation 的 onError/onSettled
- 回滚只回滚这一个 queryKey,关联的其他查询需自行处理
- onSettled 里再失效是兜底(服务端可能返回与预期不同的数据)
- 乐观更新对列表分页/排序复杂场景要谨慎(局部替换可能错位)
4. useInfiniteQuery – 无限加载
描述
分页数据的专用 Hook:initialPageParam 起手、getNextPageParam 决定下一参数(null 终止)、data.pages 是二维数组。
语法和示例
const q = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetch(`/feed?cursor=${pageParam}`).then(r => r.json()),
initialPageParam: 0,
getNextPageParam: (last, all) => last.nextCursor ?? null,
});
q.data?.pages.flatMap(p => p.items);
q.hasNextPage; q.isFetchingNextPage;
q.fetchNextPage();
// 双向加载
getPreviousPageParam: (first) => first.prevCursor ?? null;注意事项
- getNextPageParam 返回 undefined 会被当成还有下一页(必须显式 null)
- data.pages 是二维数组,渲染前要 flatMap(别直接 map)
- queryKey 变化会重置所有已加载页——筛选条件应放进 key
- maxPages 可限制内存(超出时丢弃最远页)
- React Native 的 FlatList 里配 onEndReached 触发 fetchNextPage
5. Table v9 – 入口与特性组合
描述
v9 重构:useTable 取代 useReactTable,tableFeatures 必填。行模型与排序函数必须在 features 里显式注册(不再隐式可用)。
语法和示例
import { useTable, tableFeatures, rowSortingFeature,
createSortedRowModel, sortFns } from '@tanstack/react-table';
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFns.alphanumeric, basic: sortFns.basic },
});
function Persons({ data }: { data: Person[] }) {
const columns = useMemo(() => buildColumns(), []); // 纯常量放外层
const table = useTable({ features, data, columns });
return (
<table>
<thead>{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>{hg.headers.map(h => (
<th key={h.id} onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
</th>))}</tr>))}</thead>
<tbody>{table.getRowModel().rows.map(row => (
<tr key={row.id}>{row.getVisibleCells().map(c => (
<td key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</td>))}</tr>))}</tbody>
</table>
);
}注意事项
- features 是必填项——v8 的隐式能力在 v9 全要显式声明
- 列定义函数必须放组件外或 useMemo,否则每次渲染重建导致状态丢失
- 行模型(sorted/filtered/paginated)要按需注册,注册顺序影响管线顺序
- flexRender 才会执行 cell/header 里的函数,直接渲染会显示函数源码
- getRowModel() 是当前视图的行,getPrePaginationRowModel 等是各阶段快照
6. columnHelper – 列定义
描述
类型安全的列工厂:accessor 声明数据源,header/cell 是渲染函数,可加排序/过滤/列宽等元数据。
语法和示例
const columnHelper = createColumnHelper<Person>();
const buildColumns = () => columnHelper.columns([
columnHelper.accessor('name', { header: '姓名', size: 120 }),
columnHelper.accessor('age', {
header: '年龄',
cell: (info) => `${info.getValue()} 岁`,
sortingFn: 'alphanumeric',
}),
columnHelper.accessor((row) => row.role, { // accessorFn 派生
id: 'role',
header: '角色',
}),
columnHelper.display({ // 无数据源的展示列
id: 'actions',
cell: ({ row }) => <button onClick={() => del(row.original.id)}>删</button>,
}),
]);注意事项
- accessorFn 必须给 id(否则无法作为 key)
- display 列没有 getValue,只能用 row.original 取数据
- sortingFn 字符串名必须在 features.sortFns 注册过
- 列定义里的函数会被多次调用,别在其中做副作用
- 变更列定义(条件判断)会让表格重建并丢失状态
7. 排序交互与状态
描述
表头 getToggleSortingHandler 切换 asc→desc→none;排序状态可由 table 内部管理(initialState)或外部受控。
语法和示例
<th onClick={h.column.getToggleSortingHandler()}>
{flexRender(h.column.columnDef.header, h.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[h.column.getIsSorted() as string] ?? ''}
</th>
// 受控排序(与服务端排序联动)
const [sorting, setSorting] = useState<SortingState>([]);
const table = useTable({ features, data, columns,
state: { sorting }, onSortingChange: setSorting });
// 服务端排序:把 sorting 转成 query 参数并放进 queryKey注意事项
- getIsSorted() 返回 false | ‘asc’ | ‘desc’,用对象索引前要收窄类型
- 受控模式(state)与服务端排序配合时,data 应是已排序的结果(别本地再排)
- 多列排序需 columnDef.enableMultiSort = true
- 排序函数对 null/undefined 的处理要显式(否则顺序不可预期)
- 手动排序(manualSorting)时不要注册 sortedRowModel
8. Router – 路由树与类型安全
描述
createRoute 声明式拼树,类型从父路由推导;文件式路由用 router-plugin 生成。beforeLoad 做守卫、loader 做取数。
语法和示例
const root = createRootRoute();
const posts = createRoute({
getParentRoute: () => root,
path: '/posts',
loader: () => fetchPosts(),
component: Posts,
});
const detail = createRoute({
getParentRoute: () => posts,
path: '$id',
loader: ({ params }) => fetchPost(params.id),
});
const tree = root.addChildren([posts.addChildren([detail])]);
const router = createRouter({ routeTree: tree });
// 嵌在已有 Provider 内
<QueryClientProvider>
<RouterProvider router={router} />
</QueryClientProvider>注意事项
- Router 必须嵌在其他 Provider 内部(否则拿不到 QueryClient)
- loader 返回的数据经 useLoaderData 获取,不是 useState 存的
- beforeLoad 抛 redirect() 做守卫,不要用 navigate 手动跳
- 路由树变更后需重新生成类型(文件式路由有代码生成步骤)
- params 的 id 是 string(URL 参数永远是字符串),要转数字自己转
9. Form v1 – 无状态表单
描述
v1 重写为无状态设计:不使用内部 store,靠 field API 读写值。校验支持 Standard Schema(Zod/Valibot)或函数。
语法和示例
const form = useForm({
defaultValues: { email: '', age: 0 },
validators: {
onSubmit: zodSchema, // 或 ({ value }) => errors
onChange: ({ value }) => value.age < 0 ? '年龄非法' : undefined,
},
});
<form.Field name="email">
{(field) => (
<>
<input value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} />
{field.state.meta.errors.join(', ')}
</>
)}
</form.Field>
// 提交态
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]}>
{([ok, busy]) => <button disabled={!ok || busy}>提交</button>}
</form.Subscribe>注意事项
- 字段错误在 field.state.meta.errors,表单级错误在 form.state.errors——别混
- canSubmit 已包含校验与 pending 状态,提交按钮只看它即可
- v1 不再有 form.state.values 之外的隐式 store,读值统一用 form.getFieldValue
- onChange 校验每次输入都跑,重校验用 onChangeAsync + debounce
- 数组字段用 form.Field 的 mode=’array’,不是 FieldArray 组件(v0 的写法)