Skip to content
常见陷阱与最佳实践

常见陷阱与最佳实践

本章汇总 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

错误处理

  • 全局配置 MutationCacheonError 做统一错误提示
  • 区分 4xx(客户端错误不重试)和 5xx(服务端错误重试)
  • throwOnError 配置给 ErrorBoundary 集成

生产检查清单

上线前自检:

  • QueryClient 不在组件内部创建
  • staleTimegcTime 根据业务场景合理设置
  • 无限查询设置了 maxPages
  • 没有 useState(data) 的双 source of truth
  • 所有 queryFn 正确处理 HTTP 错误状态码
  • queryFn 传递了 signalfetch
  • 乐观更新中包含了 cancelQueries
  • SSR/SSG 场景正确 await 了 prefetchQuery
  • 敏感数据不进入持久化缓存
  • DevTools 仅在开发环境渲染
  • 查询键使用统一的工厂模式(不散落各处的魔法数组)