常见陷阱与最佳实践
本章汇总 TanStack Query 开发中最常见的问题和对抗模式。每个陷阱都附有错误示例和正确做法。
陷阱速查表
| 陷阱 | 严重度 | 章节 |
|---|---|---|
| 查询键不包含所有参数 | 🔴 高 | §1 |
| queryFn 中吞掉错误 | 🔴 高 | §2 |
| 忘记 cancelQueries 导致竞态 | 🔴 高 | §3 |
| 把服务端数据同步到 useState | 🟡 中 | §4 |
| enabled 依赖的 useQuery 数据不存在 | 🟡 中 | §5 |
| select 内联函数导致过度渲染 | 🟡 中 | §6 |
| 无限查询未设 maxPages | 🟡 中 | §7 |
| QueryClient 在组件内部创建 | 🟡 中 | §8 |
| useQuery 和 useInfiniteQuery 用同一键 | 🟢 低 | §9 |
| placeholderData 与 initialData 混淆 | 🟢 低 | §10 |
| mutate 后忘记 invalidateQueries | 🟡 中 | §11 |
| SSR 预取未 await | 🟡 中 | §12 |
§1 查询键不包含所有参数
// ❌ 参数在闭包中,查询键不变 → TanStack Query 不知道参数变了
function TodoList() {
const [status, setStatus] = useState('all')
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(status), // status 变了但键没变
})
return (
<select onChange={e => {
setStatus(e.target.value)
refetch() // 手动重取 — 不可靠,且破坏声明式原则
}}>
...
</select>
)
}
// ✅ 参数成为查询键的一部分
function TodoList() {
const [status, setStatus] = useState('all')
const { data } = useQuery({
queryKey: ['todos', { status }], // status 变化 → 新请求自动触发
queryFn: () => fetchTodos(status),
})
return <select onChange={e => setStatus(e.target.value)}>...</select>
}§2 queryFn 中吞掉错误
// ❌ try/catch 但没有重新抛出 → TanStack Query 认为请求成功了
useQuery({
queryKey: ['todos'],
queryFn: async () => {
try {
return await fetch('/api/todos').then(res => res.json())
} catch (e) {
console.error(e)
// 没有 throw!函数隐式返回 undefined
// undefined 被视为"空数据"而非错误
}
},
})
// ❌ fetch 不检查 HTTP 状态码
queryFn: () => fetch('/api/todos').then(res => res.json())
// 即使 HTTP 500,fetch 也不会抛异常
// ✅ 正确:检查状态码并抛出
queryFn: async () => {
const res = await fetch('/api/todos')
if (!res.ok) {
throw new Error(`请求失败: ${res.status} ${res.statusText}`)
}
return res.json()
}💡 最佳实践:在项目中封装一个
apiClient,内部自动处理状态码检查和 JSON 解析,所有queryFn直接使用它。
§3 忘记 cancelQueries 导致竞态
// ❌ 乐观更新后,正在进行的请求可能覆盖乐观数据
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// 没有 cancelQueries!
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === updatedTodo.id ? updatedTodo : t)
)
return { previousTodos }
},
// 如果此时有一个 fetchTodos 在飞行中,
// 它完成后会用旧数据覆盖乐观更新的结果!
})
// ✅ 先取消再更新
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === updatedTodo.id ? updatedTodo : t)
)
return { previousTodos }
}§4 把服务端数据同步到 useState
// ❌ 经典反模式:双 source of truth
function TodoList() {
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const [todos, setTodos] = useState(data) // 本地拷贝!
useEffect(() => {
setTodos(data) // data 变了就同步到 state
}, [data])
// 这为什么是错的?
// 1. 两份数据 → 可能不同步
// 2. 如果多个地方修改了缓存,state 不知道
// 3. 所有手动同步逻辑 = 容易出 Bug
}
// ✅ 直接使用 data,从它派生所需的值
function TodoList() {
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// 需要过滤?用 select
const { data: activeTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.filter(t => !t.completed),
})
}🚨 陷阱:如果你发现自己写了
useState(data)+useEffect(() => setState(data), [data]),那就是在重造 TanStack Query 的缓存。服务端数据的唯一来源应该是 QueryClient 的缓存。
§5 enabled 依赖的 useQuery 数据类型不匹配
// ❌ enabled 通过后 data 仍可能是 undefined
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user.id), // ❌ TypeScript: user 可能 undefined
enabled: !!user?.id,
})
// ✅ 使用非空断言(因为 enabled 保证了 user 存在时才执行 queryFn)
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id), // user! 是安全的
enabled: !!user?.id,
})
// ✅ 更安全:在 queryFn 开头做运行时检查
queryFn: () => {
if (!user) throw new Error('User not loaded')
return fetchProjects(user.id)
}§6 select 内联函数导致过度渲染
// ❌ 每次渲染 select 都是新引用 → 每次都执行、每次都比较
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.filter(t => !t.completed),
})
// ✅ 提取为组件外部的稳定引用
const selectActive = (todos: Todo[]) => todos.filter(t => !t.completed)
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectActive,
})
// ✅ 或使用 useCallback(如果依赖外部变量)
const [status, setStatus] = useState('active')
const selectByStatus = useCallback(
(todos: Todo[]) => todos.filter(t => t.status === status),
[status]
)💡 最佳实践:将
select函数提取到 queryOptions 工厂中,让它与查询键天然绑定。
§7 无限查询未设 maxPages
// ❌ 用户连续滚动 10 分钟 → data.pages 可能有数百页 → 内存爆炸
useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// ✅ 限制内存中的页数
useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 5, // ← v5 关键配置
})§8 QueryClient 在组件内部创建
// ❌ 每次渲染都创建新的 QueryClient → 缓存丢失
function App() {
const queryClient = new QueryClient() // 每次 App 渲染都重新创建
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
// ✅ 在组件外部创建(或使用 useState 惰性初始化)
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 5 * 60 * 1000 },
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}🚨 陷阱:在 React StrictMode(开发环境)下,组件函数体会执行两次。如果在组件内创建 QueryClient,两次执行会创建两个实例,缓存各自独立,导致数据不一致。
§9 useQuery 和 useInfiniteQuery 用同一键
// ❌ 相同键但不同 API → 数据冲突
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
const { data: infiniteData } = useInfiniteQuery({
queryKey: ['todos'], // 相同键!数据结构不同 → 冲突
queryFn: fetchTodosPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// ✅ 使用不同的键结构
useQuery({ queryKey: ['todos', 'list'], queryFn: fetchTodos })
useInfiniteQuery({ queryKey: ['todos', 'infinite'], queryFn: fetchTodosPage, ... })§10 placeholderData 与 initialData 混淆
// ❌ 想保留上一页数据 → 用了 initialData(不对)
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
initialData: previousData, // 上一页数据被当作"来自合法来源的 fresh 数据"
// → TanStack Query 不会自动重取,用户看到的是旧数据
})
// ✅ 应该用 placeholderData
import { keepPreviousData } from '@tanstack/react-query'
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData, // 显示上一页数据但标记为 stale → 立即后台重取
})| 用了 | 效果 |
|---|---|
initialData: old |
视为 fresh data,不重取 → 一直是旧数据 |
placeholderData: keepPreviousData |
视为 placeholder,自动重取 → 先显示旧数据,后台更新 |
§11 mutate 后忘记 invalidateQueries
// ❌ 创建成功但列表不更新
const createTodo = useMutation({
mutationFn: api.createTodo,
// 没有 onSuccess!
})
// ✅
const createTodo = useMutation({
mutationFn: api.createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})§12 SSR 预取未 await
// ❌ prefetchQuery 返回 Promise 但没有 await → 数据未就绪就返回 HTML
export async function getServerSideProps() {
const queryClient = new QueryClient()
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// 没有 await!函数返回时 prefetch 可能还没完成
return { props: { dehydratedState: dehydrate(queryClient) } }
}
// ✅ 必须 await
export async function getServerSideProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
return { props: { dehydratedState: dehydrate(queryClient) } }
}最佳实践清单
架构与组织
- QueryClient 在组件外部创建,全局唯一
- 查询键使用工厂函数管理(
todoKeys.all/todoKeys.detail(id)) - queryOptions 工厂分离到 data 层,组件只消费
- 自定义 Hook 封装 useQuery / useMutation,组件不直接调用
缓存与性能
- 根据数据特性设置合理的
staleTime - 无限查询设置
maxPages - 对派生数据使用
select减少重渲染 -
queryFn中始终throw错误,不吞异常 - 查询键包含所有影响结果的参数
变更与同步
- mutation 成功后
invalidateQueries相关查询 - 乐观更新中首先
cancelQueries - 乐观更新在
onError中回滚previousData - 在不需乐观更新的场景,保持简单——仅
invalidateQueries
SSR
- 服务端
prefetchQuery配合await - 使用
HydrationBoundary而非手动 hydrate - 服务端
retry: 0
错误处理
- 全局配置
MutationCache的onError做统一错误提示 - 区分 4xx(客户端错误不重试)和 5xx(服务端错误重试)
-
throwOnError配置给 ErrorBoundary 集成
生产检查清单
上线前自检:
- QueryClient 不在组件内部创建
-
staleTime和gcTime根据业务场景合理设置 - 无限查询设置了
maxPages - 没有
useState(data)的双 source of truth - 所有
queryFn正确处理 HTTP 错误状态码 -
queryFn传递了signal给fetch - 乐观更新中包含了
cancelQueries - SSR/SSG 场景正确 await 了
prefetchQuery - 敏感数据不进入持久化缓存
- DevTools 仅在开发环境渲染
- 查询键使用统一的工厂模式(不散落各处的魔法数组)