何时使用
当技术栈已有 TanStack Query(旧名 React Query),需要在 React/Next.js 中处理异步服务端状态时使用:
- 搭建或重构数据拉取逻辑,替换
useEffect+useState手写拉取。 - 设计查询键(数组式、强类型、可工厂化)。
- 配置全局或单查询的
staleTime/gcTime/retry/enabled。 - 写
useMutation处理 POST/PUT/DELETE,并在成功后invalidateQueries失效缓存。 - 实现乐观更新(Optimistic Update)即时反馈 + 失败回滚。
- 集成 Next.js App Router:Server Component 预取 + Client 边界水合(hydration)。
铁律:栈里有 TanStack Query,就别再用 useEffect 手动拉数据。
不该用的边界:
- 纯客户端本地 UI 状态(表单输入、开关、弹窗)→ 用
useState/useReducer/Zustand,不是异步状态。 - 后端接口实现、数据库 ORM、GraphQL schema 设计 → 不在本技能范围。
- 全局同步状态(无网络往返)→ 别硬塞进 query 缓存。
步骤 / 指令
1. 封装 custom hook:把每个 useQuery/useMutation 包成 useUser()/useCreatePost(),
集中管理 fetcher、TS 类型、queryKey。视图层只写 const { data } = useUser(id)。
2. 设计 queryKey:必须是数组,顺序敏感;筛选/排序参数作为对象进键
(['issues', { status, sort }])。大型应用用 key 工厂避免拼写漂移。
3. 配 staleTime:默认 0(每次挂载都后台 refetch)。数据非秒级变化就设全局
staleTime(如 60s)。注意 gcTime 必须 ≥ staleTime。
4. 变更 + 失效:useMutation 的 mutationFn 发写请求,onSuccess 里
queryClient.invalidateQueries({ queryKey: [...] }) 触发后台重拉。
5. 乐观更新(需即时反馈时):onMutate 取消在途请求→快照旧值→setQueryData 抢先改;
onError 用 context 回滚;onSettled 再 invalidateQueries 与服务端对齐。
6. Next.js App Router:providers.tsx 用 useState 惰性建 QueryClient;
Server Component 里 prefetchQuery 后 dehydrate 包进 HydrationBoundary,
Client Component 用相同 queryKey 的 useQuery 直接读水合缓存,挂载不再发请求。
规则:
- 所有
useQuery/useMutation一律抽进 custom hook,视图只消费 hook。 - 用 key 工厂统一管理键,杜绝
['users']vs['user']拼错。 - 优先
invalidateQueries让其自然重拉,少用setQueryData手动改缓存(乐观更新除外)。 - 不要把 query 数据同步进本地 React state(
useEffect(() => setLocal(data), [data]));需要派生态就在 render 时直接派生。
示例
custom hook + 强类型 + 依赖查询:
import { useQuery } from '@tanstack/react-query';
type User = { id: string; name: string; status: 'active' | 'inactive' };
const fetchUser = async (userId: string): Promise<User> => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
};
export const useUser = (userId: string) =>
useQuery({
queryKey: ['users', userId], // 数组式查询键
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 分钟内视为新鲜,不后台重拉
enabled: !!userId, // 依赖查询:userId 存在才执行
});
查询键工厂(大型应用推荐):
export const issueKeys = {
all: ['issues'] as const,
lists: () => [...issueKeys.all, 'list'] as const,
list: (filters: string) => [...issueKeys.lists(), { filters }] as const,
details: () => [...issueKeys.all, 'detail'] as const,
detail: (id: number) => [...issueKeys.details(), id] as const,
};
变更 + 缓存失效:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: { title: string }) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] }); // 触发后台重拉
},
});
};
乐观更新(onMutate 抢先改 → onError 回滚 → onSettled 对齐):
export const useUpdateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodoFn,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] }); // 取消在途请求
const previousTodos = queryClient.getQueryData(['todos']); // 快照旧值
queryClient.setQueryData(['todos'], (old: any) =>
old.map((t: any) => (t.id === newTodo.id ? { ...t, ...newTodo } : t))
);
return { previousTodos }; // 作为 context 返回
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos); // 回滚
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // 成败都对齐服务端
},
});
};
Next.js App Router 预取 + 水合:
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false },
},
})
)
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
// app/posts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import PostsList from './PostsList';
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPostsServerSide });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList /> {/* Client 组件用相同 queryKey 读缓存,挂载不再发请求 */}
</HydrationBoundary>
);
}
注意事项
- 无限拉取循环:多因
queryFn在返回前抛未捕获异常(默认 retry 3 次),或被包进不稳定的useEffect。调试时设retry: false定位。 staleTimevsgcTime(旧名cacheTime):staleTime决定何时后台重拉;gcTime决定组件卸载后非活动数据在内存留存多久。gcTime < staleTime会让数据在变陈旧前就被回收,务必gcTime ≥ staleTime。- 默认
staleTime: 0意味着每次组件重挂载都触发后台 refetch;数据非秒级变化时设全局staleTime省请求。 - App Router 中 Server 与 Client 必须用同一
queryKey,否则水合失配仍会发网络请求。 refetchOnWindowFocus: false可避免切 Tab 时的激进重拉,按业务取舍。- 本技能只覆盖前端异步状态层;接口契约、鉴权、错误码语义需结合具体后端验证,勿当作环境无关结论。输入/权限/成功判据缺失时应停下追问。
互见
- requires:无。
- related:
code-reviewer(审查 React 数据层改动的正确性与坏味道)。 - combines_with:无。
采编自 sickn33/antigravity-awesome-skills(MIT)。