useEffect 依赖数组怎么用?

依赖数组不是“控制执行次数”的开关,而是 effect 对响应式数据的声明

先记住核心规则:effect 使用了某个会随渲染变化的值,就应该让它出现在依赖数组中;React 会在 render 阶段逐项比较新旧依赖值,并在提交更新后执行需要重新同步的 effect。

useEffect(setup, dependencies);

本文只讲依赖数组。关于 effect 和布局 effect 的区别,可以阅读useEffect vs useLayoutEffect

1. 三种写法

空数组:[]

useEffect(() => {
  const connection = connect();
  return () => connection.disconnect();
}, []);

含义是:这个 effect 不依赖后续渲染中的响应式值。它会在首次提交后执行 setup,组件卸载时执行 cleanup,后续重新渲染不会因为依赖变化而重新执行。

典型场景包括初始化只依赖常量的第三方实例、订阅固定的外部资源等。不要把它机械理解成类组件的 componentDidMount;开发环境 Strict Mode 可能额外执行一次 setup/cleanup 检查,代码必须能够安全地重复执行。

不传依赖数组:useEffect(setup)

useEffect(() => {
  reportRender();
});

每次组件提交更新后都可能执行。它通常不是首选,因为任何 state 或父组件更新都可能触发这个 effect。只有确实需要响应每次提交时才使用,并确保 cleanup 正确。

传入依赖数组:[count, userId]

useEffect(() => {
  const subscription = subscribe(userId, count);
  return () => subscription.unsubscribe();
}, [userId, count]);

effect 会在首次提交后执行;之后 React 逐项比较依赖值,只有至少一个依赖发生变化时,才会先执行旧 cleanup,再执行新的 setup。

依赖值使用 Object.is 比较,而不是深度比较:

Object.is(NaN, NaN); // true
Object.is({}, {});   // false

所以对象、数组和函数只要引用变化,就会被视为依赖变化。

2. render 阶段和 commit 阶段分别做什么?

useEffect 的 setup 不会在 render 阶段执行。组件函数执行到 useEffect 时,React 主要是在当前 Fiber 上登记这个 effect,保存 setup、dependencies 等信息:

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = connect(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

可以把一次更新理解成:

render 阶段
执行组件函数
遇到 useEffect,记录本次 setup 和新的 dependencies
比较新旧 dependencies,决定是否标记这个 effect 需要执行
构建 workInProgress Fiber 树



commit 阶段
把 DOM 更新提交到页面
执行需要清理的旧 cleanup
调度 useEffect 的新 setup

新的 dependencies 来自当前这次 render 执行 useEffect 时传入的第二个参数;旧的 dependencies 来自上一次已经提交的 current Fiber 中保存的 Hook/effect 记录。React 在构建 workInProgress Fiber 时,可以通过对应的 current Fiber 找到上一次的 Hook,从而取出旧 dependencies。

workInProgress Fiber
本次 render 保存的新 dependencies

        对比 Object.is

current Fiber
上一次 commit 后保存的旧 dependencies

如果某次 render 被中断或丢弃,它产生的 dependencies 不会成为下一次比较的旧值。只有成功 commit 的那棵 Fiber 树,才会变成后续更新中的 current Fiber。

因此更准确地说:render 阶段负责登记 effect 并比较依赖,commit 阶段负责提交更新,useEffect 的 setup 会在 commit 后被调度执行。

3. 什么应该放进依赖数组?

应该放入 effect 中使用的响应式值,例如:

  • 组件的 props;
  • useStateuseReducer 返回的 state;
  • 在组件函数体内创建的对象、数组和函数;
  • 从 context 中读取的值。
function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState("https://example.com");

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [serverUrl, roomId]);
}

不要为了让 lint 通过而随意删除依赖。eslint-plugin-react-hooksexhaustive-deps 规则通常能帮助发现闭包和同步问题。

有些值不需要作为依赖:

  • setter(如 setCount)的引用由 React 保证稳定;
  • useRef 返回的 ref 对象引用稳定;
  • 模块级常量和组件外部定义的稳定函数。

ref.current 的变化不会触发渲染,不能依赖它来驱动 effect 重新执行。如果需要响应某个值的变化,应把它放进 state 或 props。

4. 闭包陷阱

每次渲染都是一次独立的函数调用,effect 捕获的是创建它的那次渲染中的变量:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <button onClick={() => setCount((value) => value + 1)}>{count}</button>;
}

这个定时器捕获的是首次渲染的 count,因此后续打印的仍可能是 0。这不是 React 没有更新 state,而是 effect 没有重新创建,闭包仍然使用旧快照。

解决方式一:加入依赖

useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);

  return () => clearInterval(timer);
}, [count]);

每次 count 变化都会重建定时器。逻辑正确,但如果订阅或初始化成本较高,可能需要进一步重构。

解决方式二:只更新状态时使用函数式更新

useEffect(() => {
  const timer = setInterval(() => {
    setCount((value) => value + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []);

updater 会接收 React 当前处理到的状态,因此不需要从闭包读取 count

解决方式三:确实需要读取最新值时使用 ref

function Logger({ value }) {
  const latestValue = useRef(value);

  useEffect(() => {
    latestValue.current = value;
  }, [value]);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(latestValue.current);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return null;
}

ref 适合保存不需要触发渲染的最新值,但它会绕开 React 的响应式数据流,不应作为逃避依赖数组的默认方案。

如果使用的是支持 useEffectEvent 的 React 版本,也可以把“需要读取最新值、但不希望它触发 effect 重新同步”的逻辑拆出去。Effect Event 本身不应该放进依赖数组:

function Page({ url, shoppingCart }) {
  const onVisit = useEffectEvent((visitedUrl) => {
    logVisit(visitedUrl, shoppingCart.length);
  });

  useEffect(() => {
    onVisit(url);
  }, [url]);
}

这里访问 url 是响应式逻辑,url 变化就应该重新记录访问;读取 shoppingCart.length 只是记录当时的最新信息,不希望购物车变化也触发一次访问记录。

5. 对象、数组和函数依赖怎么办?

下面的 options 每次父组件渲染都会创建新对象:

function Parent() {
  const options = { color: "red" };
  return <Child options={options} />;
}

function Child({ options }) {
  useEffect(() => {
    applyOptions(options);
  }, [options]);
}

即使内容相同,options 引用也不同,effect 仍会重新执行。

优先按下面顺序处理:

方案一:在 effect 内创建只供 effect 使用的对象

function Child({ color }) {
  useEffect(() => {
    applyOptions({ color });
  }, [color]);
}

这样依赖从对象引用变成了真正影响 effect 的原始值。

方案二:依赖具体字段

useEffect(() => {
  applyOptions(options.color);
}, [options.color]);

仅当 effect 确实只使用 color 时才能这样写;如果使用了整个对象,就不能只填一个字段。

方案三:确实需要稳定引用时使用 useMemouseCallback

const options = useMemo(() => ({ color }), [color]);
const handleMessage = useCallback((message) => {
  onMessage(message);
}, [onMessage]);

缓存是性能优化手段,不是让依赖数组“看起来不变”的万能修复。应先确认重复执行确实造成了问题,再引入缓存。

6. 依赖变化时 cleanup 的顺序

当依赖发生变化时,React 处理顺序可以理解为:

旧依赖的 cleanup

新依赖的 setup
useEffect(() => {
  const connection = connect(roomId);
  connection.start();

  return () => {
    connection.stop();
  };
}, [roomId]);

这保证了切换 roomId 时旧连接会被清理,避免同时保留多个订阅。cleanup 也会在组件卸载时执行。开发环境 Strict Mode 的额外 setup/cleanup 是为了验证这段逻辑是否健壮。

7. 异步请求和竞态问题

effect 中发请求时,依赖数组只能保证“什么时候发起新请求”,不能自动保证“只有最新请求能更新状态”:

function Profile({ userId }) {
  const [profile, setProfile] = useState(null);

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

    setProfile(null);
    fetchProfile(userId).then((result) => {
      if (!ignore) {
        setProfile(result);
      }
    });

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

如果 userId1 很快切到 2,旧请求可能比新请求更晚返回。cleanup 中把旧 effect 的 ignore 标记为 true,可以避免旧结果覆盖新结果。

如果底层 API 支持取消请求,也可以使用 AbortController

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/profile/${userId}`, { signal: controller.signal })
    .then((response) => response.json())
    .then(setProfile)
    .catch((error) => {
      if (error.name !== "AbortError") {
        throw error;
      }
    });

  return () => controller.abort();
}, [userId]);

面试里可以这样总结:依赖变化时旧 cleanup 会先执行,所以 cleanup 不只用于释放订阅,也常用于取消或忽略过期异步任务。

8. 为什么 effect 会无限循环?

无限循环通常同时满足两个条件:

  1. effect 里更新了 state。
  2. 这个 state 更新导致某个依赖变化,从而再次触发 effect。
function Search({ keyword }) {
  const [options, setOptions] = useState({});

  useEffect(() => {
    setOptions({ keyword });
  }, [options, keyword]);
}

这里每次 setOptions({ keyword }) 都会创建新对象,options 引用变化后又触发 effect,于是循环继续。修复方式不是删除依赖,而是先判断这个 state 是否真的需要存在:

const options = { keyword };

如果只是根据 props 或 state 派生数据,通常应该在渲染阶段直接计算;如果计算很重,可以用 useMemo;如果确实要同步外部系统,再放进 effect。

9. 事件逻辑不要塞进 effect

用户操作直接导致的逻辑,优先写在事件处理器里,而不是通过 effect 观察某个状态再补做:

// ❌ 点击后改状态,再由 effect 发送请求
const [submitted, setSubmitted] = useState(false);

useEffect(() => {
  if (submitted) {
    submitForm();
  }
}, [submitted]);

function handleClick() {
  setSubmitted(true);
}

更直接的写法是:

function handleClick() {
  submitForm();
}

判断标准是:如果这段逻辑是“组件出现在屏幕上或某些响应式值变化后,需要和外部系统保持同步”,它适合 effect;如果它是“用户刚刚做了某个动作,所以要执行”,它更适合事件处理器。

10. SSR 和客户端限制

useEffect 只会在客户端执行,服务端渲染阶段不会执行。因此依赖 effect 才能得到的数据,首屏 HTML 中通常没有这部分结果:

function Clock() {
  const [time, setTime] = useState(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
  }, []);

  return <span>{time}</span>;
}

在 SSR 或 Next.js 场景下,面试官可能会继续问:首屏数据是否应该在服务端获取?是否会造成 hydration 后内容变化?浏览器 API 是否只能放在客户端 effect 中?这些问题的核心仍然是区分“渲染所需数据”和“客户端外部系统同步”。

11. 依赖数组不是执行次数开关

以下写法经常是信号:effect 里的逻辑和依赖设计需要重新思考:

// ❌ 通过删除依赖来“只执行一次”
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
  sendAnalytics(userId);
}, []);

如果确实要在首次提交时使用初始值,应明确说明这是业务意图,并考虑把初始值保存到 ref;如果 effect 应随 userId 变化,就应该写 [userId]

另一个常见问题是用 effect 派生本可直接计算的数据:

// ❌ 多一次渲染,还要处理依赖
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// ✅ 直接在渲染阶段计算
const fullName = `${firstName} ${lastName}`;

只有与外部系统同步时才需要 effect,例如网络、浏览器 API、订阅、定时器或第三方组件。

总结

写法触发 setup 的时机
useEffect(fn, [])首次提交后;卸载时 cleanup
useEffect(fn, [dep])首次提交后,以及 dep 发生变化后
useEffect(fn)每次提交后

最终记住这几点:

  1. 依赖数组逐项使用 Object.is 比较,不是深度比较。
  2. 新 dependencies 来自本次 render,旧 dependencies 来自上一次已提交的 current Fiber。
  3. render 阶段登记 effect 并比较依赖,useEffect 的 setup 在 commit 后执行。
  4. effect 使用的响应式值要诚实声明。
  5. 每次渲染都有自己的闭包,函数式更新可以避免读取旧 state。
  6. cleanup 会在下一次 setup 前执行,也会在卸载时执行。
  7. 异步 effect 要处理过期结果或取消请求。
  8. 无限循环通常来自 effect 更新 state 后又改变依赖。
  9. 事件触发的逻辑优先放事件处理器,不要绕到 effect。
  10. useEffect 不在服务端执行,不适合作为首屏服务端数据来源。
  11. 不要用 effect 派生普通数据,也不要把依赖数组仅当作执行次数开关。