用自定义 Hook 封装数据请求:统一 loading、error 与竞态处理在 React 组件里手写fetchuseStateuseEffect,第一次写起来很爽,写到第五个组件你就会发现:每个组件里都躺着一坨一模一样的loading、error、data三件套,而且十有八九都藏着一个竞态 bug。这篇就把这坨样板抽成一个可复用的 Hook,顺手把竞态问题一次性解决掉。先看朴素写法,它到底哪不对一个典型的「拉用户详情」组件,大多数人第一版会这么写:function UserProfile({ userId }) { const [data, setData] useState(null); const [loading, setLoading] useState(false); const [error, setError] useState(null); useEffect(() { setLoading(true); fetch(/api/users/${userId}) .then((res) res.json()) .then((json) { setData(json); setLoading(false); }) .catch((err) { setError(err); setLoading(false); }); }, [userId]); if (loading) return p加载中…/p; if (error) return p出错了:{error.message}/p; return div{data?.name}/div; }这段代码有三个问题,前两个是「烦」,第三个是「错」:样板重复:换个接口,这十几行几乎原样再抄一遍。error 不会重置:userId变了重新请求,如果第一次报过错,error还留着旧值,新请求成功了页面可能还在显示错误。竞态(race condition):userId从 1 快速切到 2,两个请求同时在飞。如果 1 的响应比 2 晚回来,setData会被 1 的结果覆盖——你点的是用户 2,页面最后显示的是用户 1。这个 bug 在本地网速快时几乎复现不出来,一上线就有人报。把它抽成一个 Hook先把三件套和「请求成功/失败」的状态流转收进一个 Hook。这里用AbortController来处理竞态——组件卸载或依赖变化时,直接把上一个请求取消掉。import { useState, useEffect } from react; function useFetch(url) { const [state, setState] useState({ data: null, loading: true, error: null, }); useEffect(() { // 每次 url 变化都新建一个控制器,旧请求会被 abort const controller new AbortController(); // 重新进入请求态,顺手清掉上一轮的 error setState({ data: null, loading: true, error: null }); fetch(url, { signal: controller.signal }) .then((res) { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then((data) setState({ data, loading: false, error: null })) .catch((error) { // abort 触发的报错不是真错误,直接忽略,别写进 state if (error.name AbortError) return; setState({ data: null, loading: false, error }); }); // 清理函数:url 变化或卸载时取消在途请求 return () controller.abort(); }, [url]); return state; }组件侧一下清爽下来:function UserProfile({ userId }) { const { data, loading, error } useFetch(/api/users/${userId}); if (loading) return p加载中…/p; if (error) return p出错了:{error.message}/p; return div{data.name}/div; }竞态为什么就没了?因为userId一变,useEffect的清理函数先跑controller.abort(),把用户 1 的在途请求取消,它的.then就走进AbortError分支被忽略,永远不会setData。最终落地的一定是最后一次请求的结果。坑一:把三个 state 合并成一个对象,不要拆成三个你可能想把data / loading / error拆成三个useState。别这么干。拆开后你在 catch 里要连写setData(null); setLoading(false); setError(err),三次 setState 触发的中间渲染可能出现「loading 已 false 但 error 还没更新」的空窗态。合并成一个对象、一次setState落地,状态永远自洽。坑二:URL 拼接对象参数导致死循环useFetch依赖url这个字符串,字符串按值比较,稳。但如果你想扩展成useFetch(url, options),而options是组件里每次渲染新建的对象字面量:// 反例:每次渲染 options 都是新对象,useEffect 每次都重跑,死循环 const { data } useFetch(/api/list, { headers: { X-Token: token } });对象每次渲染引用都变,useEffect的依赖比较认为「变了」,于是无限请求。解决办法是把 options 用useMemo固定引用,或者干脆把它序列化后作为依赖:function useFetch(url, options) { // 用序列化后的字符串做依赖,内容不变就不重跑 const optionsKey JSON.stringify(options ?? {}); useEffect(() { const controller new AbortController(); setState({ data: null, loading: true, error: null }); fetch(url, { ...options, signal: controller.signal }) .then((res) { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then((data) setState({ data, loading: false, error: null })) .catch((error) { if (error.name AbortError) return; setState({ data: null, loading: false, error }); }); return () controller.abort(); // 依赖用 optionsKey 而不是 options 本身,避开引用陷阱 // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, optionsKey]); return state; }坑三:需要「手动重新请求」怎么办上面的 Hook 只在url变化时请求。但很多场景要一个「重试」按钮,或者提交表单后主动刷新列表。加一个refetch,用一个自增的 tick 触发useEffect:import { useState, useEffect, useCallback } from react; function useFetch(url) { const [state, setState] useState({ data: null, loading: true, error: null }); const [tick, setTick] useState(0); // refetch 只是把 tick 1,间接触发下面的 effect,引用保持稳定 const refetch useCallback(() setTick((t) t 1), []); useEffect(() { const controller new AbortController(); setState({ data: null, loading: true, error: null }); fetch(url, { signal: controller.signal }) .then((res) { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then((data) setState({ data, loading: false, error: null })) .catch((error) { if (error.name AbortError) return; setState({ data: null, loading: false, error }); }); return () controller.abort(); }, [url, tick]); // tick 变化即重新请求 return { ...state, refetch }; }用起来:function UserProfile({ userId }) { const { data, loading, error, refetch } useFetch(/api/users/${userId}); if (loading) return p加载中…/p; if (error) return ( div p出错了:{error.message}/p button onClick{refetch}重试/button /div ); return div{data.name}/div; }什么时候别自己写,直接上库这个 Hook 适合中小项目、或你想搞清楚原理时用。但如果你的应用有大量数据请求,还需要缓存、请求去重、后台自动刷新、分页,自己维护这些会越来越重——这时候直接上 TanStack Query(React Query)或 SWR,它们把缓存和竞态都处理好了,自己造轮子不划算。判断标准很简单:只是想去掉样板 修竞态,自定义 Hook 够了;开始需要缓存和跨组件共享数据,换库。小结手写fetch useState useEffect的三件套样板,重复且容易藏竞态 bug,抽成useFetch一次解决。把data / loading / error合并成一个对象、一次setState落地,状态自洽,不会出现中间空窗态。竞态用AbortController治:依赖变化时清理函数abort()掉在途请求,AbortError分支直接忽略,最终落地的永远是最后一次请求。三个高频坑:对象型 options 用JSON.stringify做依赖避免死循环;要手动刷新用自增tickuseCallback稳定refetch引用。一句话记忆点:自定义 Hook 治样板和竞态,缓存和共享交给 React Query。