依赖数组不是「想让 effect 什么时候跑」的开关,而是「这个 effect 用到了哪些外部值」的声明。理解这一点,大部分 useEffect 的坑会自己消失。

React useEffect 依赖数组与闭包陷阱 示意图

闭包捕获的是渲染时的值

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

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

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

空依赖让 effect 只在挂载时执行一次,回调里读到的 count 就永远是首次渲染的值。修法有两种,取决于语义:

setCount(c => c + 1);

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

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

第一种用函数式更新,直接从源头消除依赖;第二种补全依赖,代价是每秒重建一次定时器,通常不是想要的效果。优先选能消除依赖的写法,而不是把依赖补全了事。

请求竞态:清理函数比依赖更重要

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

    fetch(`/api/posts?q=${keyword}`)
        .then(res => res.json())
        .then(data => {
            if (!cancelled) setPosts(data);
        });

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

输入变化很快时,先发出的请求可能后返回,把旧结果盖在新结果上。cancelled 标记或 AbortController 都能解决,清理函数就是 React 为此提供的钩子。

依赖里的函数与对象

每次渲染都会创建新的函数与对象,直接放进依赖会让 effect 每次都执行。两种处理:把函数移到组件外,或用 useCallback 固定引用。

const load = useCallback(() => {
    return fetch(`/api/posts/${id}`).then(res => res.json());
}, [id]);

useEffect(() => {
    load().then(setPost);
}, [load]);

依赖数组里出现「不是 state 也不是 props 的值」,通常说明这个值应该在渲染时算出来,而不是在 effect 里读。

不需要 effect 的场景

  • 由用户点击触发的请求:写在事件处理函数里;
  • 根据 props 计算的派生数据:直接在渲染时算,或用 useMemo;
  • props 变化时重置组件状态:给组件加 key,让 React 直接重建。

开发模式下 effect 执行两次

StrictMode 会刻意「挂载 → 卸载 → 再挂载」,用来暴露清理不干净的问题。看到接口被请求两次不要急着关 StrictMode,先确认清理函数是否真的撤销了订阅与请求;生产构建不会双执行。

小结

写法优先级:能消除依赖就消除 → 必须依赖就用函数式更新或 useCallback 固定引用 → 一定要清理。重渲染本身的优化思路见React 重渲染优化。