FORMA

Effect 与副作用

useEffect 用于在组件渲染后执行与外部系统同步的逻辑(请求、订阅、手动改 DOM 等)。见 State

核心概念

useEffect 解决的问题是:「组件渲染出来之后,我需要让某个外部系统(DOM、网络、第三方库、浏览器 API)与当前的 props/state 保持同步」。它不是「在某个时机运行一段代码」的万能钩子,而是专门为同步这件事设计的——理解这一点,才能判断什么时候该用 Effect,什么时候不该用。

基本用法

tsx
import { useEffect, useState } from "react";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setUser(data);
      });
    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (!user) return <p>加载中…</p>;
  return <p>{user.name}</p>;
}
部分说明
依赖数组 [userId]仅当 userId 变化时重新执行 effect
清理函数 return () => {...}下次 effect 执行前或组件卸载时调用,用于取消订阅、忽略过期请求

依赖数组

写法行为
省略第二参数每次渲染后都执行(慎用)
[]挂载后执行一次(注意 StrictMode 开发下可能执行两次)
[a, b]ab 变化时执行

依赖须包含 effect 内读取的所有响应式值(props、state、组件内变量),否则可能读到陈旧闭包。由 ESLint react-hooks/exhaustive-deps 辅助检查。

示例:依赖遗漏导致的陈旧闭包

tsx
function SearchBox() {
  const [query, setQuery] = useState("");
  const [debounced, setDebounced] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebounced(query); // 若依赖数组漏写 query,这里永远是首次渲染时的空字符串
    }, 300);
    return () => clearTimeout(timer);
  }, [query]); // 正确:包含 query

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

订阅外部事件的完整示例

tsx
function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize); // 必须清理,否则组件卸载后仍在监听
  }, []);

  return width;
}

resize 事件、WebSocket 连接、第三方库实例(地图、图表)都遵循同一个模式:在 Effect 中建立连接,在清理函数中断开

Effect 适用场景

适合:连接外部系统、订阅、非 React 控制的 DOM 操作。

不适合(优先考虑其他方式):

  • 由事件触发的业务逻辑 → 写在事件处理函数中。
  • 派生数据 → 用 useMemo 或在渲染时直接计算。
  • 服务端数据缓存 → 考虑 TanStack Query 等。

反例:本该在事件处理函数里的逻辑

tsx
// 反例:用 Effect 模拟「点击后跳转」,多了一次不必要的渲染,且逻辑意图不直观
function Form() {
  const [submitted, setSubmitted] = useState(false);
  useEffect(() => {
    if (submitted) {
      navigate("/success");
    }
  }, [submitted]);

  return <button onClick={() => setSubmitted(true)}>提交</button>;
}

// 正确:直接在事件处理函数中处理
function Form() {
  function handleSubmit() {
    navigate("/success");
  }
  return <button onClick={handleSubmit}>提交</button>;
}

「由用户操作直接触发的后续动作」应该写在事件处理函数里,而不是通过一个 state 变量间接触发 Effect——后者多一次渲染,且难以看出因果关系。

useLayoutEffect

在浏览器绘制前同步执行,用于读取布局并同步更新 DOM,避免闪烁。一般优先 useEffect,仅在测量 DOM 后需立即改样式时使用 useLayoutEffect

tsx
function Tooltip({ targetRef }: { targetRef: RefObject<HTMLElement> }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    const rect = targetRef.current!.getBoundingClientRect();
    // 在浏览器绘制前完成测量与定位,避免用户看到 tooltip 先出现在错误位置再跳到正确位置
    setPosition({ top: rect.bottom, left: rect.left });
  }, [targetRef]);

  return <div ref={tooltipRef} style={{ position: "absolute", ...position }}>提示内容</div>;
}

最佳实践

  • 写 Effect 前先问:「这是不是在同步外部系统?」如果答案是「不是」,大概率不该用 Effect。
  • 依赖数组交给 ESLint react-hooks/exhaustive-deps 检查,不要手动省略依赖来"控制"执行时机——应该通过重构代码结构来控制。
  • 网络请求类 Effect 一律处理竞态(取消/忽略过期响应),否则快速切换 userId 等参数时可能显示错误的数据。
  • 订阅/连接类 Effect 必须返回清理函数,对称地建立与销毁。
  • 复杂的数据获取逻辑迁移到专门的库(TanStack Query 等),而非手写一堂 useEffect + useState 处理 loading/error/data 三态。

常见坑

现象常见原因处理
切换参数后短暂显示旧/错误数据未处理请求竞态(旧请求晚于新请求返回)cancelled 标志位或 AbortController 忽略过期结果
组件卸载后报「在已卸载组件上更新状态」警告未清理订阅/定时器/请求回调清理函数中取消订阅,或用取消标志位
Effect 无限循环执行依赖数组包含每次渲染都新建的对象/函数useMemo/useCallback 稳定依赖,或调整依赖粒度
ESLint 提示缺依赖但加上后逻辑「变了」加上依赖后 Effect 执行时机变化,暴露了原有设计问题重构代码而非禁用 lint 规则
开发环境下 Effect 执行了两次StrictMode 故意双重调用以检测副作用问题确保 Effect 具备正确的清理逻辑,而不是抑制该行为

延伸阅读

  • State:与 Effect 配合的状态管理
  • 生态:TanStack Query 替代手写数据请求 Effect
  • 渲染与协调:Effect 在 Commit 阶段之后执行

参考文献

以下链接在编写时均可正常访问:

Series

hooks

2 / 4