起因
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 默认选。
踩过的坑
-
queryKey 不稳定:每次 render 新建对象 / 数组 → query 永远 stale。
useMemo(() => ['user', id], [id])或者扁平['user', id]。 -
mutation 后忘 invalidate:UI 不刷新。永远在
onSuccess
invalidate 相关 query。 -
staleTime: 0 + refetchOnWindowFocus:tab 切回来都 refetch,
API 调用爆炸。生产 staleTime 至少 30s-1min。 -
useQuery在条件渲染分支里:违反 hooks 规则。用enabled: !!id:
tsx useQuery({ queryKey: ['user', id], queryFn: ..., enabled: !!id }) -
useQuery直接 throw error:导致 React error boundary 接管。
用error字段做条件渲染,或useErrorBoundary: true显式启用。
登录后参与评论。