很多前端项目里,获取数据的代码都长这样:
useEffect(() => {
fetch("/api/books")
.then((res) => res.json())
.then(setBooks);
}, []);
它看起来简单直接,所以常常被当成“标准起手式”。但如果你真的把业务系统做大,就会发现这类写法只适合 demo,不适合长期维护。
问题不在于 fetch 这个 API 本身,而在于“把请求直接塞进组件副作用里”会让很多本该被认真处理的事情,悄悄变成遗漏项。
一次请求真正带来的,不只有“成功后的数据”,还有一整串派生状态:
如果这些状态都不管,页面在用户眼里就会很粗糙。
最典型的例子就是加载态。没有 loading,用户看到的不是“正在获取数据”,而是“页面像卡住了一样毫无反应”。一旦请求失败,如果没有 error 分支,用户又会自然地把锅甩给前端。
所以问题不是“能不能请求成功”,而是“请求过程中用户究竟经历了什么”。
竞态问题经常出现在搜索、筛选、分页这些高频交互里。
比如用户快速从第 1 页翻到第 10 页。页面理论上应该显示第 10 页的数据,但网络响应顺序并不等于请求发出顺序。如果第 3 页的响应更晚才回来,它完全可能把第 10 页的结果覆盖掉。
这就是竞态:用户最后一次操作是对的,页面最终展示却是错的。
可以把它想象成理发店同时接到两个互相冲突的需求。你先说“烫大波浪”,后来说“剃光头”,最后成品是什么,取决于谁先动手,而不是你先说了什么。
在代码里,这种问题不会靠“多写几个 if”自然消失。只要请求之间存在重叠,状态就必须被系统化管理。
再看一个很常见的业务动作:
如果你只是“组件挂载就请求”,那么列表页很可能又重新请求一次。技术上当然能跑,但体验并不好:用户明明刚看过的数据,却还得再等一遍。
这时候你就会开始思考:
这已经不是单纯的“发请求”了,而是异步状态管理。
例如有些数据实时性要求很低,完全可以先展示缓存,再悄悄请求最新数据;如果新旧数据一致,用户甚至不需要知道背后发生过一次刷新。这样就能在“数据新鲜度”和“交互流畅度”之间找到平衡。
有些团队会说,那我做个全局请求拦截器不就行了?请求开始就转圈,请求失败就弹错误提示。
这当然比什么都不处理强,但依然不够细。
因为不同场景需要的反馈方式完全不同:
统一 loading 解决的是“有没有反馈”,却解决不了“反馈是否贴合场景”。
很多人以为数据请求的核心是“把接口调通”。其实一旦进入真实项目,核心会变成:
这也是为什么 React 官方一直不鼓励把复杂数据获取逻辑长期堆在 useEffect 里。useEffect 更适合描述副作用本身,而不是承担整套异步状态管理职责。
像 TanStack Query 这类工具,本质上不是“帮你发请求”,而是帮你管理请求带来的状态系统。
它会把很多前端迟早要面对的问题直接变成能力:
isLoading、isError 这类派生状态开箱即用queryKey 的缓存天然存在所以它真正替代的,不是 fetch 或 axios,而是你手写的那一大堆零散状态与边界处理。
前端当然可以直接在 useEffect 里 fetch,就像你也可以把所有代码都写进一个文件里。
问题从来不是“能不能跑”,而是“跑多久以后会开始难受”。
当页面只是一段 demo,这种写法轻便、省事、成本低。但一旦你要面对加载态、错误态、竞态、缓存、预加载、重试这些真实问题,你就会发现:你要管理的从来不是一次请求,而是一整套异步状态。
理解这一点,才算真正从“会调接口”走向“会设计数据获取方案”。