React Query 管服务端状态:别再手写一堆 loading 和 error

要不要为一份数据引入 React Query,我现在的判断标准很简单:这份数据的"真身"在后端,还是在前端自己手里。前者交给库,后者留给 useState。这条线画清楚,大半的纠结就没了。

弹窗开关、当前 tab、输入框的值、还没提交的表单草稿——这些前端自己说了算,没有第二个人能改它,也不存在"过期"一说,用 useState 或表单库就够,硬塞进 React Query 只是徒增概念。反过来,用户列表、订单详情、字典配置这些,前端手里握的只是后端数据的一份副本,它会过期、会被别人改、可能请求失败、可能被好几个组件同时用、改完之后还要同步。副本越多、生命周期越复杂,手写 loading/data/error 就越兜不住,这时候才值得把它交出去。

手写三件套什么时候开始失控

很多 React 项目一开始请求数据都很直接:

1const [data, setData] = useState(null)
2const [loading, setLoading] = useState(false)
3const [error, setError] = useState(null)
4
5useEffect(() => {
6  setLoading(true)
7  fetchList()
8    .then(setData)
9    .catch(setError)
10    .finally(() => setLoading(false))
11}, [])

单个页面这么写没问题。问题的分水岭在于:当项目里每个列表、详情、弹窗都各自这么来一遍,重复请求、错误处理不一致、刷新逻辑散落、组件卸载后还在 setState 这些问题会同时冒出来。

最能说明这一点的是后台列表页这种场景:列表、筛选、详情抽屉、编辑弹窗都在各自请求同一份数据。用户改完状态,有的地方刷新了,有的还是旧的。到这一步问题已经不是接口慢,而是前端根本不知道哪份数据才是最新的。这就是"同一份服务端数据被多处缓存"超过了手写状态能管住的复杂度,该换工具了。

React Query 解决的不是"少写几行请求代码",而是把接口数据当成服务端状态来统一管理。

queryKey 是数据的身份

判断"交给库"之后,第一个要想清楚的就是 queryKey,因为它决定了库怎么区分和复用这些副本。最基础的查询:

1const { data, isLoading, error } = useQuery({
2  queryKey: ['users'],
3  queryFn: fetchUsers,
4})

queryKey 不是随便起个名字,而是这份数据的身份。带参数的列表要把参数放进 key:

1const query = { page, keyword, status }
2
3const { data } = useQuery({
4  queryKey: ['users', query],
5  queryFn: () => fetchUsers(query),
6})

key 漏了 keyword,用户搜索后可能拿到旧缓存;key 每次构造不稳定,又会导致不必要的请求。我的习惯是先把参数规范化,再同时给 key 和请求函数用。这也是判断要不要拆分 query 的地方:两块数据身份不同,就用不同的 key,别硬合。

分页列表还有个体验细节:翻页时 key 里的 page 变了,React Query 默认会把当前数据清掉进入 loading,页面一闪白。加上 keepPreviousData,翻页时先留着上一页的数据、后台加载下一页,翻页就顺滑很多:

1useQuery({
2  queryKey: ['users', query],
3  queryFn: () => fetchUsers(query),
4  keepPreviousData: true,
5})

要不要开它也是判断:翻页、切筛选这种"内容会整体替换"的列表适合开,而进入全新页面本就该有 loading,就不必。

缓存不是脏东西,是新鲜度的表达

刚用 React Query 时,很多人一看到"缓存"就紧张,担心数据不新。缓存不是为了骗人,而是为了控制数据新鲜度。用户从列表进详情再返回列表,没必要每次都白屏重新加载;先显示缓存、再后台重新验证,体验通常更好。

staleTime 表达数据多久内算新鲜:

1useQuery({
2  queryKey: ['dict', 'roles'],
3  queryFn: fetchRoles,
4  staleTime: 5 * 60 * 1000,
5})

这里又要按业务拿捏一次:字典、权限选项、地区数据变化极慢,适合更长缓存;订单状态、审批状态变化快,就要短一些,甚至操作后主动刷新。缓存策略应该来自业务,而不是默认全关。

顺带说一个容易混的点:staleTimecacheTime 不是一回事。staleTime 管"多久内不重新请求",cacheTime 管"没有组件用它之后,缓存在内存里再留多久才被回收"。我见过有人把两个混着调,结果要么频繁请求,要么内存里积了一堆早该释放的旧数据。分清这两条时间线,缓存行为才好预期。

还有几个自动重新验证的开关也值得按业务定:refetchOnWindowFocus 让用户切回标签页时刷新,做实时性要求高的看板很合适,但表单填一半被这么刷一下就很烦;refetchOnReconnect 在断网重连后刷新,弱网环境有用。这些默认大多是开的,我会按页面性质单独关掉不合适的那几个,而不是全局一刀切。

mutation 后要让旧数据失效

数据改了,相关 query 不会自动知道自己过期。

1const queryClient = useQueryClient()
2
3const mutation = useMutation({
4  mutationFn: updateUser,
5  onSuccess: () => {
6    queryClient.invalidateQueries({ queryKey: ['users'] })
7  },
8})

这表示更新成功后,让用户列表失效,下一次使用时重新请求。改的是详情,也可以同时失效详情:

1queryClient.invalidateQueries({ queryKey: ['user', userId] })

失效范围要控制。范围太小,页面显示旧数据;范围太大,接口被无意义刷新。简单状态切换也可以直接更新缓存:

1queryClient.setQueryData(['user', userId], (old) => ({
2  ...old,
3  enabled: true,
4}))

但直接写缓存要谨慎,尤其列表和详情结构不一致时,不要把半截数据塞进详情缓存。

再进一步是乐观更新:先把缓存改成"预期成功后的样子",让界面立刻响应,失败了再回滚。这套值不值得做,也得掂量一次成本——它能让点赞、开关这类高频操作零延迟,但要自己在 onMutate 里备份旧值、在 onError 里还原,复杂度不低:

1useMutation({
2  mutationFn: toggleEnabled,
3  onMutate: async (next) => {
4    await queryClient.cancelQueries({ queryKey: ['user', userId] })
5    const prev = queryClient.getQueryData(['user', userId])
6    queryClient.setQueryData(['user', userId], (old) => ({ ...old, ...next }))
7    return { prev }
8  },
9  onError: (_err, _next, context) => {
10    queryClient.setQueryData(['user', userId], context.prev)
11  },
12  onSettled: () => {
13    queryClient.invalidateQueries({ queryKey: ['user', userId] })
14  },
15})

我的做法是:低频、结果重要的操作(比如提交订单),老老实实等接口回来再刷新;高频、失败代价小的操作(开关、点赞),才上乐观更新。别一上来就给所有 mutation 套这套模板。

错误处理要统一

React Query 能帮你管理 error 状态,但怎么展示仍然要业务决定。我一般在请求层把错误统一成一个结构:

1type RequestError = {
2  code: string
3  message: string
4  status: number
5  fields?: Record<string, string>
6}

页面里再按场景处理:

1if (error?.code === 'UNAUTHORIZED') {
2  return <LoginExpired />
3}
4
5if (error) {
6  return <ErrorState message={error.message || '加载失败'} onRetry={refetch} />
7}

API 错误必须有个去处,不能任由它裸奔到界面上。不要让未知错误直接把组件炸成白屏,也不要所有错误都只弹一个 toast。这里也有一次判断:错误是就地展示(页面里画一块错误态、带重试按钮),还是抛给上层的 ErrorBoundary 统一接。列表页这种局部失败不该拖垮整页,就地处理;而整页依赖的关键数据挂了,交给 ErrorBoundary 统一兜底更省心。React Query 支持通过 useErrorBoundary 选项把查询错误抛给 ErrorBoundary 捕获,用哪种取决于这块数据失败后页面还能不能用。

React Query 默认对失败的 query 会自动重试几次,这在移动端弱网下有帮助,但对"明确失败"的场景(比如 404、无权限)重试是浪费,我会按 status 关掉这类不该重试的情况:

1useQuery({
2  queryKey: ['user', userId],
3  queryFn: () => fetchUser(userId),
4  retry: (count, error) => {
5    if (error.status === 404 || error.status === 403) return false
6    return count < 2
7  },
8})

不要把 query 数据再复制一份

一个常见坏味道:

1const { data } = useQuery(...)
2const [list, setList] = useState([])
3
4useEffect(() => {
5  setList(data || [])
6}, [data])

只是展示的话,没必要复制。复制之后就有两份状态:query cache 和本地 state,后面更新哪一份都让人困惑。这也回到最开始那条判断标准——服务端状态就该留在库里管,别顺手又拷一份到本地状态里,那等于把刚交出去的东西又拿回来自己扛。

只有一种情况我会复制:编辑表单。接口数据只是初始值,用户编辑的是草稿,草稿属于本地状态。

1useEffect(() => {
2  if (data) {
3    form.reset(data)
4  }
5}, [data, form])

提交成功后再失效 query 或更新缓存。

派生数据用 select,别用额外 state

还有个和"别复制"相邻的坑:需要对 query 数据做一层转换(过滤、排序、取某几个字段)时,别再起一个 useState + useEffect 去算。React Query 的 select 就是干这个的,它在缓存数据之上做派生,源数据变了自动重算,也不会多出一份状态:

1const { data: activeUsers } = useQuery({
2  queryKey: ['users'],
3  queryFn: fetchUsers,
4  select: (users) => users.filter((u) => u.enabled),
5})

我把这条也归进"服务端状态别外流"的判断里:只要转换结果完全由服务端数据决定,就让它待在 query 体系内。

依赖查询和预取,也要按场景取舍

有些数据的请求要等另一份数据先回来。比如先拿到用户,再用用户的部门 id 去查部门详情。硬写 useEffect 串起来又会退回手写状态那套,React Query 用 enabled 表达这种依赖更干净:

1const { data: user } = useQuery({
2  queryKey: ['user', userId],
3  queryFn: () => fetchUser(userId),
4})
5
6const { data: dept } = useQuery({
7  queryKey: ['dept', user?.deptId],
8  queryFn: () => fetchDept(user.deptId),
9  enabled: !!user?.deptId,
10})

第二个 query 在 user 没回来前不会发。这里的判断是:什么时候该拆成两个依赖 query,什么时候干脆让后端一个接口返回聚合数据。请求链一长,前端等待时间是累加的,能在后端聚合就别在前端串。

预取是另一面。列表页悬停某一行时,提前把详情拉进缓存,用户真点进去几乎无感:

1const prefetch = (id) =>
2  queryClient.prefetchQuery({
3    queryKey: ['user', id],
4    queryFn: () => fetchUser(id),
5  })

要不要预取也要按场景权衡:详情接口轻、命中率高就值得,接口重或用户很少点进去,预取只是白白增加请求量。

一条判断标准,两类状态

哪些交给 React Query:列表和详情、字典和配置、搜索结果、分页和无限加载,以及一切需要缓存和刷新的接口数据。哪些不放进去:输入框临时值、弹窗开关、当前 hover 状态、未提交的表单草稿。

服务端状态和本地状态分清楚,组件会轻很多。React Query 不是替代所有状态管理,而是专门把"来自服务端的那一类状态"管好。 判断要不要用它,回到最初那句话就够了:这份数据的真身在后端还是在前端手里。