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})
这里又要按业务拿捏一次:字典、权限选项、地区数据变化极慢,适合更长缓存;订单状态、审批状态变化快,就要短一些,甚至操作后主动刷新。缓存策略应该来自业务,而不是默认全关。
顺带说一个容易混的点:staleTime 和 cacheTime 不是一回事。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 不是替代所有状态管理,而是专门把"来自服务端的那一类状态"管好。 判断要不要用它,回到最初那句话就够了:这份数据的真身在后端还是在前端手里。