TanStack Query (React Query):把"fetch + loading + error + cache"一站搞定

起因

React 里每次写"组件 mount 时 fetch 数据,loading 状态显示 spinner,
error 显示 toast,组件 unmount 时 abort"——这一套 boilerplate 在
useState + useEffect 里写起来 30 行:

function UserCard({ id }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let alive = true
    setLoading(true)
    fetch(`/api/users/${id}`)
      .then(r => r.json())
      .then(data => { if (alive) { setUser(data); setLoading(false) } })
      .catch(e => { if (alive) { setError(e); setLoading(false) } })
    return () => { alive = false }
  }, [id])

  if (loading) return <Spinner />
  if (error) return <ErrorBlock />
  return <div>{user.name}</div>
}

而且这里还没处理:去重(同时多个组件查同一 id)、refetch 策略、
后台静默更新、optimistic update、stale-while-revalidate……

TanStack Query(前身 React Query)把这套抽象成 hook,代码减少 80%。

解决方案

npm i @tanstack/react-query

顶层 Provider

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,           // 1 分钟内复用 cache,不 refetch
      gcTime: 5 * 60 * 1000,           // 5 分钟没人订阅就 gc
      retry: 2,
      refetchOnWindowFocus: true,
    },
  },
})

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools />     {/* dev 用 */}
</QueryClientProvider>

useQuery:上面那 30 行变成

import { useQuery } from '@tanstack/react-query'

function UserCard({ id }) {
  const { data: user, isPending, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
  })

  if (isPending) return <Spinner />
  if (error) return <ErrorBlock message={error.message} />
  return <div>{user.name}</div>
}

5 行。自动:

  • mount 时 fetch
  • unmount abort(如果 queryFn 支持 signal)
  • 同 queryKey 多组件去重(只一次请求)
  • staleTime 内复用 cache,不重 fetch
  • focus tab / 网络恢复时 background refetch
  • error 时自动重试

queryKey 是核心

useQuery({ queryKey: ['user', id] })       // GET /api/users/:id
useQuery({ queryKey: ['users', { tag: 'admin', page: 2 }] })
useQuery({ queryKey: ['posts', userId, 'comments'] })

queryKey 像 cache 字典的 key。同 key 共享一份 cache。
对象 / 数组按值比较(deep equality)。

失效 cache

import { useQueryClient } from '@tanstack/react-query'

function CreatePostForm() {
  const qc = useQueryClient()
  const mutate = async (data) => {
    await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) })
    // 让所有 posts 相关查询失效,触发自动 refetch
    qc.invalidateQueries({ queryKey: ['posts'] })
  }
  // ...
}

invalidateQueries({ queryKey: ['posts'] }) 让所有以 ['posts', ...]
开头的 cache 标记 stale,触发 refetch。

useMutation:POST / PUT / DELETE

import { useMutation, useQueryClient } from '@tanstack/react-query'

function DeleteButton({ id }) {
  const qc = useQueryClient()
  const mutation = useMutation({
    mutationFn: (postId) => fetch(`/api/posts/${postId}`, { method: 'DELETE' }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  return (
    <button
      onClick={() => mutation.mutate(id)}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '删除中...' : '删除'}
    </button>
  )
}

Optimistic update(界面立刻反映,再发请求)

const mutation = useMutation({
  mutationFn: (newPost) => fetch('/api/posts', ...).then(r => r.json()),

  onMutate: async (newPost) => {
    // 取消正在跑的 query
    await qc.cancelQueries({ queryKey: ['posts'] })
    // 保存 snapshot
    const prev = qc.getQueryData(['posts'])
    // 乐观更新 cache
    qc.setQueryData(['posts'], (old) => [...old, newPost])
    return { prev }
  },

  onError: (err, newPost, ctx) => {
    // 回滚
    qc.setQueryData(['posts'], ctx.prev)
  },

  onSettled: () => {
    qc.invalidateQueries({ queryKey: ['posts'] })
  },
})

UI 立刻看到新 post,服务端失败自动回滚。用户体验大幅改善。

分页 / 无限滚动

import { useInfiniteQuery } from '@tanstack/react-query'

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam = 0 }) =>
    fetch(`/api/posts?page=${pageParam}`).then(r => r.json()),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
})

return (
  <>
    {data?.pages.map(page => page.items.map(p => <PostCard key={p.id} {...p} />))}
    {hasNextPage && (
      <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
        加载更多
      </button>
    )}
  </>
)

prefetch

页面将进入前先预加载下个页面:

function PostLink({ id }) {
  const qc = useQueryClient()
  return (
    <a
      href={`/posts/${id}`}
      onMouseEnter={() => qc.prefetchQuery({
        queryKey: ['post', id],
        queryFn: () => fetch(`/api/posts/${id}`).then(r => r.json()),
      })}
    >
      详情
    </a>
  )
}

鼠标 hover 时预 fetch,用户点进去秒开。

Devtools 极爽

<ReactQueryDevtools /> 在 dev 模式右下角浮窗:

  • 所有 query 状态(fresh / fetching / stale / inactive)
  • cache 内容
  • 手动 invalidate / refetch 测试

调试缓存问题神器。

SSR / Next.js 集成

// app/posts/page.tsx
import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query'

export default async function PostsPage() {
  const qc = new QueryClient()
  await qc.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => fetchPosts(),
  })
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <PostList />
    </HydrationBoundary>
  )
}

服务端预先取数据塞进 client cache,hydrate 后客户端立刻有数据。

效果

我们的 SPA 改造后:

  • 网络请求量降 40%(多组件共享 cache + staleTime 复用)
  • 用户感知到的"loading"次数下降 60%(cache 命中秒显示)
  • bug 数下降明显(边界情况 framework 处理而非自己写)
  • 代码量净减少 30%

替代品

  • SWR(Vercel 出品):API 更简洁,能力少一些
  • Apollo Client(GraphQL):GraphQL 项目优秀
  • RTK Query(Redux Toolkit):已经用 Redux 的话
  • TanStack Query:通用、最强大、生态最大

新项目无 GraphQL → TanStack Query 默认选。

踩过的坑

  1. queryKey 不稳定:每次 render 新建对象 / 数组 → query 永远 stale。
    useMemo(() => ['user', id], [id]) 或者扁平 ['user', id]

  2. mutation 后忘 invalidate:UI 不刷新。永远在 onSuccess
    invalidate 相关 query。

  3. staleTime: 0 + refetchOnWindowFocus:tab 切回来都 refetch,
    API 调用爆炸。生产 staleTime 至少 30s-1min。

  4. useQuery 在条件渲染分支里:违反 hooks 规则。用 enabled: !!id
    tsx useQuery({ queryKey: ['user', id], queryFn: ..., enabled: !!id })

  5. useQuery 直接 throw error:导致 React error boundary 接管。
    error 字段做条件渲染,或 useErrorBoundary: true 显式启用。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。