为什么 React Hooks 写着写着就乱了?

很多人刚上手 React Hooks 时,都会有一个类似的心路历程:一开始觉得真香,函数组件加 useState/useEffect,写着比 class 清爽多了。
过几个月回头看以前的代码,发现一堆奇怪的问题——
组件里一大坨 useEffect,改谁都怕炸。
明明状态不多,逻辑却分散在各种 effect、ref、回调里。
偶尔还会遇到那种「打印出来是旧值」「调试时行为忽明忽暗」的闭包问题。
这篇我们先不讲任何高深原理,就围绕三个问题来拆解:
为什么「什么都往 useEffect 里塞」会让组件越来越难维护?
为什么「能算出来的值」一旦进了 state,逻辑就会开始扭曲?
为什么闭包在 Hooks 里格外容易坑到你?
先把这些坏味道认清楚,后面几篇再分别从 useEffect 边界、闭包实战、计算属性等角度展开。
一、一个典型的「能跑但很难维护」组件长什么样?
先看一段「很多项目里都长得差不多」的代码:
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [filtered, setFiltered] = useState<Product[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
// 初始化拉数据
useEffect(() => {
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
setItems(data.items);
setTotal(data.total);
})
.finally(() => setLoading(false));
}, [keyword]);
// 根据 keyword 过滤本地列表(有点多余,但真实项目里经常这样写)
useEffect(() => {
const next = items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
);
setFiltered(next);
}, [keyword, items]);
// 当 filtered 变化时更新总数
useEffect(() => {
setTotal(filtered.length);
}, [filtered]);
// 监听 initialKeyword 变化(比如父组件路由切换)
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>共 {total} 个商品</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}这段代码有几个典型特征:
状态很多:keyword、items、filtered、total、loading。
effect 很多:初始化请求、过滤、统计、同步 props。
看起来「职责划分清晰,每个 effect 做一件事」,实际上问题不少。
你可能会说:能跑啊,看起来也还行,有什么问题?
问题在于:这段代码里,很多状态根本不应该存在,很多 effect 也不应该存在。
二、状态一多,逻辑就一定复杂吗?
我们从数据的角度,重新梳理一下这个组件「真正需要知道」的东西:
用户当前的关键词 keyword。
后端返回的原始商品列表 items。
那么:
filtered 可以完全通过 items + keyword 计算出来。
total 可以完全通过 filtered.length(或者 items.length)算出来。
也就是说:
filtered 和 total 都是标准的「派生状态」(derived state),本质是计算属性,而不是原始状态。
一旦你把这些可推导的值也塞进 state:
你就要负责「保持这些状态之间永远一致」。
任何一个路径忘记同步,就会出现「显示的总数不对」「过滤结果和数据不匹配」这种阴间 bug。
React 官方文档里反复强调: 当某个值可以由 props 或 state 计算出来时,不要再为它单独维护一份 state。
现在回头看这段代码,你会发现:
我们为了「方便」,给 filtered、total 都加了 state。
然后又为了让它们「看起来同步」,写了两段 effect 去维护。
相当于:本来一条公式可以解决的事,被硬生生拆成了三份状态 + 两个副作用。
三、useEffect 被当成「逻辑垃圾桶」之后会怎样?
上面那个组件还有另一个问题: 几乎所有逻辑都被「拆碎」塞进了 useEffect:
拉数据用一个 effect。
过滤用一个 effect。
统计用一个 effect。
同步 props 用一个 effect。
问题在于,useEffect 本来的定位是:
用来把 React 的状态,和某个「外部系统」同步,比如请求、订阅、DOM 操作。
而过滤和统计,都是纯计算,完全可以在 render 期间完成:
不需要操作 DOM。
不需要访问浏览器 API。
不需要发网络请求。
把纯计算塞进 effect 之后,会带来这些额外成本:
性能上:
你每次更新 filtered/total,都经历「渲染 → 跑 effect → setState → 再渲染」两轮流程。
心智上:
逻辑被拆散在多个 effect 里,改某个行为需要到处翻。
很难一眼看出「这个 UI 状态到底是怎么算出来的」。
bug 风险:
某次重构时,没注意依赖数组里漏了一个变量,effect 不再按预期更新;
或者为了解决「无限循环」随意删依赖,靠感觉调到「不报错就行」。
久而久之,这个组件就从「函数 + Hooks」退化成了「函数写的 class 生命周期」:
生命周期从 componentDidMount/componentDidUpdate 变成了一堆 useEffect。
问题一样有,甚至更隐蔽。
四、闭包为什么在 Hooks 里特别容易坑你?
再看一个简化版的例子:
function SearchBox() {
const [keyword, setKeyword] = useState('');
useEffect(() => {
const id = setInterval(() => {
console.log('search with', keyword);
// fetch(`/api/search?keyword=${keyword}`)
}, 5000);
return () => clearInterval(id);
}, []); // 注意:依赖数组是 []
return (
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
);
}问题非常典型:
你以为定时器「每 5 秒用最新的 keyword 搜一次」。
实际上,effect 只在挂载时执行一次,闭包里拿到的是「初次渲染时的 keyword」。
后面用户怎么输入,打印出来的永远是空字符串。
这就是经典的「闭包 + 不老实的依赖数组」问题:
useEffect 里创建的回调,会「捕获」当时的 props 和 state。
如果依赖数组不含 keyword,effect 不会随 keyword 更新而重新执行。
回调引用的永远是旧的变量。
之所以在 Hooks 时代这个问题格外普遍,是因为:
以前 class 里你用的是 this.state,总是当前的;
现在函数组件里你用的是闭包变量,版本是跟着渲染快照走的。
更糟的是:
很多人为了躲 ESLint 的警告,直接把依赖数组改成 [] 或者随意删减。
这就等于是显式地告诉 React:「我知道这里会用到 keyword,但你不要管它变不变。」
结果就是:
代码表面上「很安静」,没有 warning,没有报错,但行为是错的。
这类问题调试起来特别浪费时间——console.log 也能看到「值是对的」, 可是 effect 里的回调拿到的就是旧值。
五、从这个例子里我们真正应该学到什么?
回到我们一开始的 ProductList 示例,如果我们顺着 React 官方推荐的几个原则重写一版,会是这样:
只为「最小不可推导」的东西建 state:keyword、items、loading 等。
所有可推导值(filtered、total)都通过计算属性实现,而不是再加一层 state + effect。
useEffect 只做「拉数据」这种和外部系统打交道的事。
一个更干净的版本是:
function ProductList({ initialKeyword }: { initialKeyword: string }) {
const [keyword, setKeyword] = useState(initialKeyword);
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/products?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
if (cancelled) return;
setItems(data.items);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [keyword]);
const filtered = useMemo(
() =>
items.filter(item =>
item.name.toLowerCase().includes(keyword.toLowerCase())
),
[items, keyword]
);
const total = filtered.length;
useEffect(() => {
setKeyword(initialKeyword);
}, [initialKeyword]);
if (loading) return <div>Loading...</div>;
return (
<div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
<div>共 {total} 个商品</div>
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}可以看到:
useEffect 只剩两个:一个拉数据,一个同步 initialKeyword。
组件内的「数据关系」一目了然:items → filtered → total。
状态数量变少了,逻辑也跟着变清晰了。
你甚至可以继续优化:如果 initialKeyword 只在挂载时用一次,而不会后续变化,可以通过路由 key 或父组件控制,彻底删掉第二个 effect。
六、这一篇,你可以带走的「代码审查清单」
第 1 篇我们不急着讲具体技巧,只先种下几个判断标准。 你可以直接拿去 review 自己项目里的 Hooks 代码:
这个组件里有多少个 state?每一个 state 是否「最小不可推导」?
如果某个字段完全可以通过别的 state/props 算出来,那它就是冗余的。
这个组件里有多少个 useEffect?
它们是不是在做和「外部世界」的同步?
有没有纯计算逻辑被塞进 effect 里?
有没有 useEffect 的依赖数组明显「少写」了东西,只是为了躲开 ESLint 提示?
有没有那种「状态之间相互驱动」的链条:A 改变触发 effect 改 B,B 改变触发 effect 改 C?
这种链式更新往往意味着:B、C 其实是可以算出来的。
有没有「打印出来是旧值」的疑难杂症?
这类问题基本都和闭包 + 不完整依赖数组有关。
在 Google 上继续关注
把 HeyBinyang 添加为 Google 首选来源
如果你愿意继续在 Google 里读到我的更新,可以把这个站点添加为 preferred source,之后更容易在相关内容场景里看到它。
SHARE
分享
分享这篇文章。