React 学习里最容易走偏的一个点,就是把 useEffect 和 useRef 当成“遇到问题就能兜底的工具”。一旦组件逻辑复杂,很多人会本能地:
- 需要同步一点东西就写
useEffect; - 需要记点值就写
useRef; - 页面行为不稳定了再补几个依赖项。
结果代码能跑,但没人说得清副作用为什么在这里、什么时候执行、什么时候应该清理。这一篇真正要讲的,不是 Hook 语法,而是 React 里的副作用治理。
什么是副作用?
从 React 项目视角看,凡是“不是单纯根据 state 和 props 计算 UI”的动作,都可以视为副作用,比如:
- 发请求;
- 操作 DOM;
- 订阅事件;
- 设置定时器;
- 与外部系统同步;
- 手动写日志、埋点或本地缓存。
副作用本身不可避免,问题从来不是“有没有副作用”,而是副作用是否出现在正确的边界里。
useEffect 真正适合做什么?
useEffect 更适合承接那些“在渲染结果确认之后,再和外部世界发生协作”的逻辑。它的关键不是“组件挂载后执行”,而是:
- 这段逻辑是不是副作用;
- 它依赖哪些输入;
- 输入变化后是否真的应该重新执行;
- 是否需要清理。
如果这些问题不先想清楚,Effect 很快就会从“副作用边界”退化成“逻辑垃圾桶”。
为什么很多人会把 useEffect 用成黑盒?
因为它看起来很万能:能发请求、能监听、能同步、能清理。于是很多本来不该出现在 Effect 里的逻辑也被塞进去,比如:
- 本该是派生值的内容;
- 只是为了把两个 state 硬同步起来;
- 某些明明可以在事件里直接完成的动作;
- 某些只是因为没想清楚状态归属而出现的补丁逻辑。
这也是为什么 React 项目里最典型的坏味道之一,就是 Effect 越写越多、依赖项越补越乱。
useRef 真正适合什么?
useRef 的核心价值,不是“一个不会触发重渲的变量”,而是:
- 保存 DOM 引用;
- 保存某些不参与渲染、但需要跨渲染周期保留的值;
- 在副作用逻辑里保持稳定引用;
- 避免不必要的状态更新。
它适合那些“组件内部需要记住,但变化后不应该推动界面更新”的东西。比如:
- 某个定时器 ID;
- 某个上一次值;
- 某个第三方实例引用;
- 某个 DOM 节点句柄。
如果你把所有“懒得理状态”的值都放进 useRef,那只是把问题藏起来,不是真正解决边界问题。
为什么副作用管理一定要和清理一起看?
因为很多 React 页面的问题不在于副作用没执行,而在于它执行完了却没结束。典型问题包括:
- 页面离开后事件监听还在;
- 请求返回时组件早已不在当前上下文;
- 定时器还在继续跑;
- 某些外部实例没有销毁;
- 旧的同步逻辑继续影响新状态。
所以 Effect 的真正难点,不是写那一段逻辑,而是同时负责它的生命周期。
依赖项为什么总让人头疼?
因为依赖项本质上是在回答:这段副作用到底受哪些输入控制。也就是说,它不是“让 ESLint 别报错”的列表,而是在表达逻辑边界。
一旦依赖项没有想清楚,常见后果就是:
- 该更新时没更新;
- 不该重跑时频繁重跑;
- 闭包拿到旧值;
- 为了压错误提示去手动跳过依赖,最后留下更隐蔽的问题。
所以依赖项不是写法细节,而是副作用语义的一部分。
把 Effect 和 Ref 放回项目里,最重要的判断是什么?
更稳的顺序通常是:
1. 先问这段逻辑是不是副作用;2. 如果不是副作用,优先别进 Effect;3. 如果是副作用,再明确它依赖什么输入;4. 看看它是否需要清理;5. 只有当某个值不该触发重渲时,再考虑用 useRef。
这个顺序非常重要。很多 React 代码混乱,不是因为 Hook 太难,而是因为顺序反了,先写 Effect,再补意义。
最常见的几个误区
1. 用 useEffect 同步本地派生状态
很多时候这类逻辑其实应该直接计算。
2. 为了绕开重渲,把状态偷塞进 useRef
这样只是在制造不可见状态。
3. 依赖项看着烦就压掉
会留下闭包和时序问题。
4. 副作用只管执行,不管清理
页面切换和复杂交互里非常容易出问题。
总结
useEffect 和 useRef 真正重要的,不是会不会写,而是你是否能用它们把副作用边界表达清楚。Effect 负责和外部世界协作,Ref 负责保存不应触发渲染的稳定引用,二者都必须围绕“依赖、时机、清理、可解释性”来使用。只要你始终先判断逻辑性质,再决定是否上 Hook,副作用管理就会稳很多。