本文由 Huifer 撰写,他是 TanStack Ship 的独立开发者与维护者。 我在多个 SaaS 产品中使用 TanStack Query v5(前身 React Query)交付过生产级应用。这些指南来自一手生产经验:真实代码、实测行为,以及对局限性的诚实说明。
信息来源:tanstack.com、github.com/TanStack、web.dev、developers.mozilla.org。 最后更新:2026-10-05 · Changelog
TanStack Query(前身 React Query)v5 在 API 和性能上都有显著改进。在用它交付了数十个生产应用之后,下面是那些真正经得起规模考验的模式。
查询键工厂(Query Key Factories)
影响最大的一个模式:结构化的查询键工厂。随手拼的字符串键会制造难以察觉的 bug,让缓存失效行为变得不可预测。
// queries/userKeys.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// 使用
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
})
// 让所有 users 相关查询失效
queryClient.invalidateQueries({ queryKey: userKeys.all })
// 只让列表查询失效
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
类型安全的 queryOptions
v5 引入了 queryOptions(),用于可复用、类型安全的查询配置:
import { queryOptions } from '@tanstack/react-query'
export const userQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 分钟
})
// 在组件中
const { data: user } = useQuery(userQueryOptions(userId))
// 在路由 loader 中(TanStack Router 集成)
export const Route = createFileRoute('/users/$userId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(userQueryOptions(params.userId)),
})
有意识地设置 staleTime
不要对所有查询都用默认的 staleTime: 0。想一想数据变化的频率:
// 用户偏好很少变化 —— 缓存 1 小时
const { data: preferences } = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
staleTime: 60 * 60 * 1000,
})
// Feed 数据持续变化 —— 不缓存
const { data: feed } = useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 0,
refetchInterval: 30_000,
})
// 商品目录 —— 中等缓存
const { data: products } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
staleTime: 5 * 60 * 1000,
})
乐观更新(Optimistic Mutations)
乐观更新能让 UI 有"即时响应"的感觉。v5 让这套写法干净了很多:
function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// 取消在途查询,避免覆盖乐观更新
await queryClient.cancelQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
// 快照旧值,用于回滚
const previous = queryClient.getQueryData(todoKeys.detail(updatedTodo.id))
// 乐观写入
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), (old) => ({
...old,
...updatedTodo,
}))
return { previous }
},
onError: (err, updatedTodo, context) => {
// 失败时回滚
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), context?.previous)
},
onSettled: (data, error, updatedTodo) => {
// mutation 结束后总是重新拉取
queryClient.invalidateQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
},
})
}
游标分页的无限查询
对实时数据来说,游标分页比偏移分页更可靠:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', filters],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, ...filters }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// 拍平页面用于渲染
const posts = data?.pages.flatMap((page) => page.items) ?? []
与 Error Boundary 集成
把 TanStack Query 的 throwOnError 与 React Error Boundary 结合使用:
// 把错误抛给最近的错误边界
const { data } = useQuery({
queryKey: ['critical-data'],
queryFn: fetchCriticalData,
throwOnError: true,
})
// 在路由或组件树中
function RouteErrorBoundary({ error }: { error: Error }) {
return (
<div className="error-state">
<h2>出错了</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>重试</button>
</div>
)
}
用预取提升体验
在鼠标悬停时预取数据,可以彻底消灭加载状态:
function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient()
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
onMouseEnter={() => {
queryClient.prefetchQuery(postQueryOptions(post.id))
}}
>
{post.title}
</Link>
)
}
总结
最重要的几个要点:
- 查询键工厂 —— 从第一天起就结构化你的查询键
queryOptions()—— 在组件和 loader 之间共享查询配置- 有意识的 staleTime —— 让缓存时长匹配数据变化频率
- 乐观更新 —— 总是处理回滚,并在结束后重新拉取
- 悬停预取 —— 你能找到的最便宜的性能优化
这些模式帮我们在生产环境中省下了无数排查陈旧数据和缓存失效 bug 的时间。从查询键工厂开始,逐步铺开。