← [ BACK_TO_NOTES ]

React useEffect

React 中用于同步外部系统的副作用 Hook 及其常见误区。

#frame #react #concept #growing

[!info] related notes

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 对应 removeEventListener
  • setInterval 对应 clearInterval
  • connect 对应 disconnect
  • observe 对应 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>
}

这里会一直打印旧值,因为定时器回调拿到的是创建它那次渲染里的闭包。

常见解法有三种:

  1. 把依赖写全,让 effect 随值变化重建
  2. 用 useRef 保存最新值
  3. 如果只是更新状态,优先用函数式更新

最容易踩的坑

  • 把本该放在事件函数里的逻辑塞进 useEffect
  • 为了“让它只执行一次”而故意漏写依赖
  • 在 effect 中更新依赖项本身,形成无限循环
  • 忘记清理订阅、定时器和外部连接
  • 用 effect 保存完全可以直接算出来的派生状态

最短判断题

如果去掉这段代码后,组件只是“少了一段外部同步行为”,那它可能属于 useEffect;如果去掉后连界面本身的计算都说不清了,那往往说明它本来就不该写进 useEffect。

与 useLayoutEffect 的关系

本页只维护 useEffect 本身;绘制时机差异统一看 React useEffect vs useLayoutEffect。

学习检查

不看正文回答:

  1. useEffect 的职责为什么更准确地说是“同步外部系统”,而不是“render 后执行代码”?
  2. 哪些逻辑其实不需要 Effect?
  3. 依赖数组为什么应该真实反映 Effect 读取的响应式值?
  4. cleanup 在依赖变化与卸载时分别解决什么问题?
  5. stale closure 为什么经常和错误依赖一起出现?
  6. Strict Mode 开发环境中的额外 setup/cleanup 检查想暴露什么问题?
  7. 一个网络订阅、一个派生值、一个点击事件分别应该放在哪里?
创建于 2026/3/19 更新于 2026/9/30
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本