引入 TanStack Query
要点
- TanStack Query 是一个专门解决服务端状态管理的库,它可以帮助我们管理客户端组件的远程数据状态,包括数据获取、缓存、失效、重拉等
- 这次不是只给 web 单独装一个包,而是把 @tanstack/react-query 放进工作区共享依赖
- TanStack Query 不是装完就能直接在组件里用,它需要一个 QueryClientProvider
- 只演示 useQuery 不够,因为你只会看到「自动读取」这一类场景
- apps/web/src/client-api/system/client-ping-demo.tsx
内容
1. 概述
前面已经把 API 路由拆开,也把请求封装成统一的 http 模块了。接下来如果还要在客户端组件里直接请求接口,只靠 useEffect + useState 很快就会遇到几个重复问题:
- 加载状态每个组件都要手写
- 错误状态每个组件都要手写
- 成功后的缓存、刷新、重试逻辑每个组件都要手写
- mutation 完成后,相关 query 的重新拉取也要自己管
这类问题本质上不属于业务,而属于客户端数据管理。
所以这一步最合适的做法,就是把 TanStack Query 接进来,并把它设置成多个子站共享依赖。然后在 web 子站里写一个同时覆盖 query + mutation 的演示案例,把整个接入方式跑通。
2. 为什么这里适合引入 TanStack Query
TanStack Query 是一个专门解决服务端状态管理的库,它可以帮助我们管理客户端组件的远程数据状态,包括数据获取、缓存、失效、重拉等。
TanStack Query 解决的不是「怎么发请求」,而是「客户端组件拿到远程数据之后,怎么管理它的生命周期」。
当前项目里,这一点和前面的请求封装正好是上下两层关系:
http.ts负责统一请求入口api/*.ts负责接口语义映射- TanStack Query 负责客户端数据状态管理
也就是说,请求是怎么发出去的,还是走你已经封装好的 http。TanStack Query 只关心:
- 什么时候发
- 当前是不是 loading
- 当前是不是 error
- 数据有没有缓存
- mutation 成功后要不要让某个 query 失效重拉
这也是为什么当前场景特别适合接入它。
一方面,仓库里已经有统一的接口调用函数,不需要再为 TanStack Query 重写一套 fetcher。另一方面,web 子站已经有客户端演示场景,正好适合验证 useQuery 和 useMutation。
3. 为什么要把它做成共享依赖
这次不是只给 web 单独装一个包,而是把 @tanstack/react-query 放进工作区共享依赖。
原因很简单:
web用得上admin后面也大概率会用上- 这类基础能力属于前端通用依赖,不该让每个子站各写一份版本号
所以先在 pnpm-workspace.yaml 里补 catalog:
// pnpm-workspace.yaml
catalog:
"@tanstack/react-query": ^5.76.2然后子站里统一通过 catalog: 引用:
// apps/web/package.json
{
"dependencies": {
"@tanstack/react-query": "catalog:"
}
}// apps/admin/package.json
{
"dependencies": {
"@tanstack/react-query": "catalog:"
}
}这样后面升级版本时,也只需要改一处。
4. Provider
TanStack Query 不是装完就能直接在组件里用,它需要一个 QueryClientProvider。
这一步最容易做错的地方,是把 QueryClient 建在服务端组件里,或者直接建成模块级单例。当前更稳妥的做法,是单独做一个 client provider 组件,并在 provider 内部用 useState 保证实例只在客户端初始化一次。
当前接入方式如下:
// apps/web/src/providers/query-provider.tsx
"use client"
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { useState } from 'react'
// QueryClient 必须放在 client provider 中创建,避免服务端组件环境下共享同一个运行时实例。
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}然后把它挂到 apps/web/app/layout.tsx 根布局里:
// apps/web/app/layout.tsx
import { QueryProvider } from '@/providers/query-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
)
}这样 web 子站里所有客户端组件都能直接使用 useQuery 和 useMutation。
5. 演示案例同时覆盖 query 和 mutation
只演示 useQuery 不够,因为你只会看到「自动读取」这一类场景。
只演示 useMutation 也不够,因为你看不到 query 缓存和失效重拉的配合。
当前最合适的验证方式,就是做一个 query + mutation 联动 demo:
useQuery调GET /healthuseMutation调POST /rpc/system/ping- ping 成功之后,主动让 health query 失效并 refetch
这样一套下来,TanStack Query 最重要的几个能力就都验证到了:
- loading / error / success 状态
- query 缓存
- 手动 refetch
- mutation 成功后的 query invalidation
6. 先补一个客户端接口文件
既然 query demo 要在客户端组件里跑,那对应的请求函数也要准备好。
这次新增了一个专门给客户端用的 health 请求文件:
// apps/web/src/client-api/system/health.api.ts
import type { ApiResponse, HealthResponse } from '@repo/contracts'
import { http } from '@/http'
export function getClientHealth() {
return http.get<HealthResponse>('/health') as Promise<ApiResponse<HealthResponse>>
}这里沿用的还是前面封装好的 http 模块。也就是说,TanStack Query 接入之后,并没有推翻原来的请求层,而是在其上面继续叠加客户端状态管理。
这点很重要,因为它说明整个结构是兼容的:
http负责请求基础设施client-api/*负责客户端调用语义- TanStack Query 负责状态和缓存
7. web 子站里的 query + mutation 演示怎么写
当前演示组件放在:
apps/web/src/client-api/system/client-ping-demo.tsx
改造后的核心逻辑如下:
// apps/web/src/client-api/system/client-ping-demo.tsx
"use client"
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getClientHealth } from '@/client-api/system/health.api'
import { postClientPing } from '@/client-api/system/ping.api'
const HEALTH_QUERY_KEY = ['system-health']
export function ClientPingDemo() {
const queryClient = useQueryClient()
// 获取数据
const { data, isLoading, isError, isSuccess, error, refetch } = useQuery({
queryKey: HEALTH_QUERY_KEY,
queryFn: getClientHealth,
})
// 发送请求
const pingMutation = useMutation({
mutationFn: () => postClientPing({ name: 'client-web' }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: HEALTH_QUERY_KEY })
},
})
return (
<div>
{/* ...UI */}
</div>
)
}这里有两个点需要单独讲清。
第一,health 用 useQuery。
因为它属于页面进入后自动读取、可缓存、可 refetch 的读操作。
第二,ping 用 useMutation。
因为它属于按钮触发的写操作,请求成功后,再主动让 ['system-health'] 失效重拉。
这就是 TanStack Query 最常见也最实用的搭配方式。
8. 这个演示到底验证了什么
当前 demo 实际上同时验证了四件事。
一是多个子站共享依赖已经成立。
@tanstack/react-query 已经收进 workspace catalog,web 和 admin 都通过统一版本引用。
二是 Provider 挂载位置正确。
QueryProvider 已经在 web 根布局生效,客户端组件可以直接使用 hooks。
三是 query 路径已经跑通。
useQuery 能正常拉 GET /health,并展示:
isPendingisErrorisSuccessdataerror
同时还支持手动 refetch()。
四是 mutation 路径已经跑通。
useMutation 能正常发 POST /rpc/system/ping,并在成功后让 health query 失效重拉。
这说明当前仓库里,客户端组件的远程状态管理链路已经完整接上了。
9. 这种接入方式为什么适合当前项目
当前项目适合这样接的原因,不只是「TanStack Query 很流行」,而是它刚好贴合现在的分层状态。
你已经有:
contracts负责共享协议http负责统一请求入口api.ts/client-api.ts负责接口语义映射
现在再加上 TanStack Query,分层关系就很清楚:
- contract 层定义数据形状
- request 层负责把请求发出去
- query 层负责客户端状态管理
- 页面层只负责展示
这种结构后面不管是 web 还是 admin,都能沿着同一模式继续扩展。
在后续的接口请求中,我们项目会大规模使用 TanStack Query 来管理客户端状态,而不是使用基础的 useEffect + useState
NOTE
tanstack query 的用法比较多,本文只是做一个简单的引导,如果你要完整的学习,可以参考我的这本付费小册,当然,你也可以直接观察后续我们在项目中的使用方式