TanStack Query v5 最佳实践:生产环境验证的 7 个核心模式

来自 5 万+ 生产查询的 TanStack Query v5 实战模式:查询键工厂、缓存策略、乐观更新、失效处理与性能修复,全部经过真实生产验证。

Huifer
Huifer
May 5, 20268 min read
其他语言:English · Deutsch

本文由 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,让缓存失效行为变得不可预测。

ts
// 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(),用于可复用、类型安全的查询配置:

ts
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。想一想数据变化的频率:

ts
// 用户偏好很少变化 —— 缓存 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 让这套写法干净了很多:

ts
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) })
    },
  })
}

游标分页的无限查询

对实时数据来说,游标分页比偏移分页更可靠:

ts
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 结合使用:

tsx
// 把错误抛给最近的错误边界
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>
  )
}

用预取提升体验

在鼠标悬停时预取数据,可以彻底消灭加载状态:

tsx
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>
  )
}

总结

最重要的几个要点:

  1. 查询键工厂 —— 从第一天起就结构化你的查询键
  2. queryOptions() —— 在组件和 loader 之间共享查询配置
  3. 有意识的 staleTime —— 让缓存时长匹配数据变化频率
  4. 乐观更新 —— 总是处理回滚,并在结束后重新拉取
  5. 悬停预取 —— 你能找到的最便宜的性能优化

这些模式帮我们在生产环境中省下了无数排查陈旧数据和缓存失效 bug 的时间。从查询键工厂开始,逐步铺开。