React useEffect
React 中用于同步外部系统的副作用 Hook 及其常见误区。
[!info] related notes
- 所属 MOC: React MOC
- 上位主题: React, React Hooks
- 关系笔记: React useEffect vs useLayoutEffect
- 相关概念: javascript, useRef, React中的事件处理与状态更新
React useEffect
useEffect 的职责不是“渲染后执行任意逻辑”,而是把组件和 React 外部系统同步起来。
一句话定义
当组件需要和网络请求、订阅、定时器、DOM API 或第三方库建立连接时,useEffect 是声明这段同步关系的地方。
什么叫“外部系统”
- 网络请求
- 事件订阅
- 定时器
- 浏览器 DOM API
- 第三方可变对象或库实例
标准心智模型
不要把 useEffect 理解成“生命周期替代品”,更准确的理解是:
当依赖是这组值时,我需要把组件和某个外部系统同步到这个状态。
useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.connect()
return () => {
connection.disconnect()
}
}, [serverUrl, roomId])
useEffect(() => {}, [])
可以把它读成:
- 首次提交后,建立连接
- 依赖变化后,先清理旧连接,再建立新连接
- 组件卸载时,最后清理一次
什么情况通常不需要 useEffect
- 只是根据
props或state计算一个值 - 点击按钮后直接在事件函数里处理即可
- 本质只是渲染逻辑的一部分
这些场景更适合直接计算、提取函数,或使用 useMemo,而不是引入副作用。
依赖数组怎么理解
依赖数组不是“手动控制执行时机的开关”,而是告诉 React:这段副作用读取了哪些响应式值。
当这些值变化时,就要重新同步。
三种依赖写法的区别
不传依赖数组
useEffect(() => {
console.log('every render')
})
每次提交后都运行。
传空数组
useEffect(() => {
console.log('mount-like')
}, [])
只在首次挂载后运行一次,卸载时执行 cleanup。
但这里捕获的是首次渲染时的闭包值。
传具体依赖
useEffect(() => {
console.log(count)
}, [count])
首次执行一次,之后依赖变化时再执行。
依赖数组的常见坑
少写依赖
useEffect(() => {
fetchUser(userId)
}, [])
如果 userId 会变化,这通常就是 bug。
对象和函数依赖反复变化
const options = { roomId }
useEffect(() => {
connect(options)
}, [options])
这里的 options 每次渲染都会是新对象,effect 就会反复重跑。
更稳的写法通常是:
useEffect(() => {
const options = { roomId }
connect(options)
}, [roomId])
为什么 cleanup 特别重要
Effect 不是“一次性执行代码”,而是“开启一段同步过程”。
所以你开始了什么,就要结束什么:
addEventListener对应removeEventListenersetInterval对应clearIntervalconnect对应disconnectobserve对应unobserve
如果 cleanup 没写对,就容易出现:
- 重复订阅
- 定时器叠加
- 内存泄漏
- 过期异步结果回写
为什么开发环境里会执行两遍
在严格模式下,开发环境会额外做一次 setup -> cleanup -> setup 的压力测试。
这通常不是 bug,而是在帮你检查:
- cleanup 是否完整
- 副作用是否可恢复
- 代码是否偷偷依赖“只执行一次”
闭包旧值为什么在 Effect 里这么常见
function Demo() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count)
}, 1000)
return () => clearInterval(id)
}, [])
return <button onClick={() => setCount(c => c + 1)}>+</button>
}
这里会一直打印旧值,因为定时器回调拿到的是创建它那次渲染里的闭包。
常见解法有三种:
- 把依赖写全,让 effect 随值变化重建
- 用
useRef保存最新值 - 如果只是更新状态,优先用函数式更新
最容易踩的坑
- 把本该放在事件函数里的逻辑塞进
useEffect - 为了“让它只执行一次”而故意漏写依赖
- 在 effect 中更新依赖项本身,形成无限循环
- 忘记清理订阅、定时器和外部连接
- 用 effect 保存完全可以直接算出来的派生状态
最短判断题
如果去掉这段代码后,组件只是“少了一段外部同步行为”,那它可能属于 useEffect;如果去掉后连界面本身的计算都说不清了,那往往说明它本来就不该写进 useEffect。
与 useLayoutEffect 的关系
本页只维护 useEffect 本身;绘制时机差异统一看 React useEffect vs useLayoutEffect。
学习检查
不看正文回答:
- useEffect 的职责为什么更准确地说是“同步外部系统”,而不是“render 后执行代码”?
- 哪些逻辑其实不需要 Effect?
- 依赖数组为什么应该真实反映 Effect 读取的响应式值?
- cleanup 在依赖变化与卸载时分别解决什么问题?
- stale closure 为什么经常和错误依赖一起出现?
- Strict Mode 开发环境中的额外 setup/cleanup 检查想暴露什么问题?
- 一个网络订阅、一个派生值、一个点击事件分别应该放在哪里?