← Back to desk

Archive Note

React Hook 学习 2:useEffect 的副作用边界

Filed As react-hook-useeffect

useEffect 是 React 里最容易被滥用的 Hook。

很多人把它当成“组件里什么逻辑都能塞的地方”,但这正是问题开始的地方。useEffect 的真正职责不是“写逻辑”,而是:

把 React 渲染结果和外部世界同步起来。

这里的“外部世界”包括:

  • 浏览器 API
  • 订阅系统
  • 定时器
  • 网络请求
  • 第三方库实例
  • 手动 DOM 操作

如果你把这个边界想清楚,useEffect 就会从“玄学 Hook”变成一个非常明确的工具。

一、原理:effect 不是为了计算,而是为了同步

组件函数本身应该尽量保持纯:

  • 给定同样的 props 和 state
  • 返回同样的 UI

但真实应用不可能只有纯计算。你总要请求数据、注册事件、创建订阅、写 localStorage。useEffect 的存在,就是为了在渲染完成后处理这些副作用。

这也是为什么很多逻辑不应该放进 effect。

错误思路

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

这里 fullName 只是派生值,不是副作用。你不需要“同步到外部世界”,而只是多绕了一层状态同步。

正确思路

const fullName = `${firstName} ${lastName}`.trim();

二、开发场景:什么才该放进 useEffect

1. 注册和清理事件监听

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener("resize", handleResize);
  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

2. 发请求并把结果放进 state

useEffect(() => {
  let cancelled = false;

  async function loadUsers() {
    const response = await fetch("/api/users");
    const data = await response.json();

    if (!cancelled) {
      setUsers(data);
    }
  }

  loadUsers();

  return () => {
    cancelled = true;
  };
}, []);

3. 和第三方实例同步

比如地图、图表、播放器、编辑器。

useEffect(() => {
  chart.setData(data);
}, [chart, data]);

三、依赖数组到底怎么理解

依赖数组不是“我想让它什么时候执行”,而是:

这个 effect 使用了哪些会变化的值?

也就是说,依赖数组应该来自 effect 的真实输入,而不是来自你的愿望。

常见错误

useEffect(() => {
  fetchUser(userId);
}, []);

这里 effect 明明用了 userId,却把依赖写成 [],就会造成数据不同步。

正确写法

useEffect(() => {
  fetchUser(userId);
}, [userId]);

四、最常见误区:把 effect 当成“生命周期替代品”

很多人脑子里还停留在 class component 时代,会把 effect 理解成:

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

这种理解不是完全错,但很容易把你带偏。更稳的方式是问:

  • 我要同步什么外部资源?
  • 这个同步依赖哪些值?
  • 当这些值变化时,我是否需要重做同步?
  • 之前的同步是否需要清理?

当你这样想,effect 的结构就自然了。

五、Strict Mode 为什么会看起来“执行两次”

开发环境里,React Strict Mode 会故意多执行一次某些 effect 流程,以帮助你暴露副作用问题。

所以你可能看到:

  • 请求发两次
  • 订阅注册两次
  • console log 多一次

这不是 React 坏了,而是在提醒你:

  • effect 是否可重复执行
  • cleanup 是否写对了
  • 有没有把不该放进 effect 的逻辑放进去了

如果一个 effect 无法承受重复执行,它的设计通常就有问题。

六、竞态问题:异步请求最容易踩的坑

当依赖变化很快时,旧请求可能比新请求更晚返回,最终把旧数据覆盖到新 UI 上。

例如搜索框:

useEffect(() => {
  let active = true;

  async function search() {
    const response = await fetch(`/api/search?q=${keyword}`);
    const result = await response.json();

    if (active) {
      setResults(result);
    }
  }

  search();

  return () => {
    active = false;
  };
}, [keyword]);

这个模式的本质不是“防内存泄漏”,而是防止过期请求污染当前界面

七、什么时候不该用 useEffect

这是比“怎么用”更重要的问题。

不要用 effect 做这些事

  • 派生数据计算
  • 事件响应后的即时逻辑
  • 只是因为“组件加载后要做点什么”就塞进去
  • 纯粹为了把一个 state 同步到另一个 state

一个判断方法

如果这段逻辑可以:

  • 在 render 里直接算出来
  • 在事件处理函数里直接做掉
  • 在数据结构设计阶段消灭掉

那就不要用 effect。

八、真实开发建议

1. 一个 effect 只做一件同步任务

不要把订阅、请求、localStorage、埋点全塞进一个 effect。

拆开后:

  • 依赖更清楚
  • cleanup 更清楚
  • 出 bug 更容易定位

2. 先想依赖,再写 effect

不是写完逻辑再补依赖数组,而是先想:

  • 这段同步依赖什么值?
  • 变化后要不要重做?

3. 把 cleanup 当成 effect 的一部分

不是“可选收尾”,而是 effect 设计的一半。

如果你注册了:

  • 事件监听
  • 订阅
  • 定时器
  • 第三方实例

那你就必须考虑对应清理。

九、这一篇最该记住的结论

  • useEffect 的职责是把 React 状态和外部世界同步
  • 依赖数组描述的是 effect 的真实输入,不是你希望它怎么执行
  • 能在 render 中推导的值,不要放进 effect
  • 异步 effect 要防竞态,不只是防报错
  • cleanup 不是附属品,而是 effect 设计本身的一部分

如果 useState 解决的是“什么值会驱动渲染”,那 useEffect 解决的就是“渲染之后怎么和外部系统对接”。