useEffect 依赖数组怎么用?

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

先记住核心规则:effect 使用了某个会随渲染变化的值,就应该让它出现在依赖数组中;React 会在提交更新后,逐项比较新旧依赖值,决定是否重新执行 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. 什么应该放进依赖数组?

应该放入 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。

3. 闭包陷阱

每次渲染都是一次独立的函数调用,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 的响应式数据流,不应作为逃避依赖数组的默认方案。

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

下面的 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]);

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

5. 依赖变化时 cleanup 的顺序

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

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

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

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

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

以下写法经常是信号: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. effect 使用的响应式值要诚实声明。
  3. 每次渲染都有自己的闭包,函数式更新可以避免读取旧 state。
  4. 不要用 effect 派生普通数据,也不要把依赖数组仅当作执行次数开关。