React开发中应避免的 15 个常见 useEffect 错误


使用 useEffect 前要问的三个问题:每次考虑使用 useEffect 时,问自己这三个基本问题:

Q1. 是否有外部系统?

useEffect 旨在将 React 组件与外部系统同步。

什么算作外部系统?

  • 网络:API、WebSockets、服务器发送事件 (Server-Sent Events)
  • 浏览器 API:DOM 操作、localStorage、地理位置 (geolocation)
  • 第三方库:分析、聊天小部件、地图
  • 订阅:实时数据库、事件流

规则:如果你不需要外部系统,你可能就不需要 useEffect

Q2. 谁触发了这个?

如果涉及外部系统,必须确定触发器:什么应该导致这个效果运行?

  • 用户操作:如果用户采取了行动,例如点击项目或输入,你需要的是事件处理程序,而不是效果。
  • 组件生命周期:如果效果应该运行是因为用户访问了某个页面,或者因为页面应该在后台执行一个操作而不管用户活动如何,那么你可能需要一个效果,但要问第二个后续问题。

Q3. 值是如何计算的?

  • props/state:如果你要计算的值是直接从 props 或 state 派生的,你应该在渲染期间直接计算它,而不是在效果内部计算。
  • 需要清理:如果你的效果执行的操作分配了资源或创建了持久连接,它需要清理以防止资源泄漏。如果组件卸载,该资源必须被释放。

15 个最常见的 useEffect 错误

类别 1:依赖项管理不当

错误 1:缺少依赖项数组导致无限渲染

当在效果内部触发状态更新,且该效果不受依赖项数组控制时,会导致组件无限重新渲染,出现“无限渲染问题”。

// ❌ 不要这样做:导致无限循环
function Counter({
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // 触发重新渲染 → 运行效果 → 触发重新渲染...
  }); // 没有依赖项数组!

  return <div>{count}</div>;
}

修复: 添加一个空依赖项数组,使其仅在挂载时运行一次。

// ✅ 这样做:仅在挂载时运行一次
function Counter({
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1);
  }, []); // 空数组 = 仅在挂载时运行一次

  return <div>{count}</div>;
}

错误 2:陈旧的状态值问题

 在刚刚更新状态的效果内部读取状态会导致不必要的渲染并显示陈旧值。

// ❌ 错误:设置后立即读取陈旧的 'user' 值
function Component({
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        console.log("Fetched user:", user); // 陈旧的值(仍为 null!)
      });
  }, []);
  return <div>{user.name}</div>;
}

✅ 修复: 直接使用数据或正确响应变化

function Component({
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!isMounted) return// 假设 isMounted 在清理函数中管理
        setUser(data);
      });
  }, []);

  // ✅ 在 'user' 实际更新时记录
  useEffect(() => {
    if (user) {
      console.log("Fetched user:", user);
    }
  }, [user]); // 依赖 user

  return <div>{user ? user.name : 'Loading...'}</div>// 添加加载状态处理
}

现在:

  • console.log 仅在 user 更新后运行。
  • 没有陈旧的读取。
  • 没有不必要的重新渲染。

错误 3:陈旧的 props

当基于 props 获取数据时,如果将该 prop 排除在依赖项数组之外,意味着效果仅在挂载时运行一次,而不管 prop 的变化。这会导致陈旧数据:

// ❌ 不要这样做
function Temp2({ userId }{
  // ...
  useEffect(() => {
    fetchUser(userId);
  }, []); // 缺少 userId 依赖项!
  // ...
}

如果 userId prop 发生变化,效果不会重新运行,组件将卡在使用原始 userId 获取的陈旧数据上。修复很简单:将 userId 添加到依赖项数组。

修复: 将你需要的任何 props 添加到依赖项数组:

// ✅ 这样做
function Temp2({ userId }{
  // ...
  useEffect(() => {
    fetchUser(userId);
  }, [userId]); // 修复了 userId 依赖项!
  // ...
}

请注意: 通过启用 eslint 插件 eslint-plugin-react-hooks 可以轻松避免这些错误。

错误 4:使用多个 useEffect 进行链式反应

如果多个效果响应相同的依赖项,它们应该合并成一个效果:

// ❌ 不要这样做:多个具有相同依赖项的 useEffect
function Temp2({ userId }{
  // ...
  useEffect(() => {
    fetchUser(userId);
  }, [userId]);

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

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

上面的每个效果在 userId 变化时都会运行,这意味着 React 必须为同一个触发器调度和清理三个独立的效果。

相反,将它们合并为一个:

// ✅ 这样做:对相关依赖项使用单一效果
function Temp2({ userId }{
  // ...
  useEffect(() => {
    fetchUser(userId);
    getProfilePicture(userId);
    setLoginInfo(userId);
  }, [userId]);
}

这减少了开销,使你的代码更易于理解,并确保所有响应 userId 更新的逻辑一起发生。

错误 5:对象和函数的不稳定依赖项

在渲染期间创建的对象和函数在每次渲染时都会被 JavaScript 视为新的引用,即使它们的内容没有改变。将它们包含在依赖项数组中会导致效果不必要地重新运行,从而导致过多的数据获取:

// ❌ 不要这样做:user 是一个不稳定的对象引用
const user = { userId123profile: {} };

useEffect(() => {
  fetchUser(user.userId);
}, [user]); // 依赖整个 user 对象

要解决此问题,只传递你需要的稳定的原始值属性,例如 user.id。不要在依赖项中添加整个对象,只添加你实际需要的属性:

// ✅ 这样做:使用原始值
const user = { userId123profile: {} };

useEffect(() => {
  fetchUser(userId); // 假设 userId 来自 props 或 state
}, [userId]); // 仅依赖 userId

如果你确实需要添加函数,你应该使用 useCallback 记忆化它:

// 对不稳定的函数引用使用 useCallback
const fetchUserProfileMemoized = useCallback(() => {
    // 获取逻辑
  },
  [ /* 依赖项 */ ],
);

useEffect(() => {
  fetchUserProfileMemoized();
}, [fetchUserProfileMemoized]); // 依赖记忆化后的函数

注意: 在现代 React 版本(React 17, 18, 19)中启用 React Compiler 后,编译器可以自动处理此问题。

类别 2:误用 Effects 处理派生状态

错误 6:在 useEffect 中计算派生状态

这可能是该 Hook 最常见的误用。如果你需要根据 props 或另一个状态的变化来更新一个状态片段,你应该在渲染期间直接派生它。

例如,在效果内部根据 firstName 和 lastName 计算 fullName 被认为是冗余且不必要的:

// ❌ 避免:冗余状态和不必要的效果
function EventTeam({ isOnTeam }{
  const [onTeam, setOnTeam] = useState(isOnTeam);

  useEffect(() => {
    setOnTeam(isOnTeam);
  }, [isOnTeam]);
}

正确的方法是直接在渲染中计算:

// ✅ 正确:在渲染期间计算。不需要 useState 或 useEffect
function EventTeam({ isOnTeam }{
  const onTeam = isOnTeam; // 直接赋值
  // ... 使用 onTeam
}

错误 7:不必要的依赖项

有时,我们需要一个值对 prop 变化做出反应。一个常见的错误是在 useEffect 中添加额外的依赖项以确保它在 prop 变化时重新运行:

// ❌ 避免:不需要 useEffect
function EventTeam({ isOnTeam, isHelping }{
  const [onTeam, setOnTeam] = useState(isOnTeam);

  useEffect(() => {
    setOnTeam(isOnTeam);
  }, [isOnTeam, isHelping]); // 不必要的依赖项 isHelping
}

由于组件在 prop 变化时会重新渲染,我们不需要为此使用 useEffect

// ✅ 修复:直接从 props 计算 isOnTeam。
function EventTeam({ isOnTeam, isHelping }{
  const onTeam = isOnTeam; // 直接赋值
  // ... 使用 onTeam
}

错误 8:重置状态/缓存

一个常见的错误是使用 useEffect 在某些 props 变化时重置状态变量:

// ❌ 避免:效果内部不必要的状态重置
function Temp2({ hackathon }{
  const [judgeDetailsCache, setJudgeDetailsCache] = useState([]);
  const [panelAssignmentsCache, setPanelAssignmentsCache] = useState([]);

  // 问题:这会导致额外的渲染和暂时不一致的状态
  useEffect(() => {
    // 当 hackathon 变化时清除缓存
    setJudgeDetailsCache([]);
    setPanelAssignmentsCache([]);
  }, [hackathon]);

  // 获取评委详情...
  useEffect(() => {
    fetchJudgeDetails(hackathon.id).then(setJudgeDetailsCache);
  }, [hackathon.id]);
}

这里的意图是每个 hackathon 应该有自己唯一的 judgesDetails 和 panelAssignments

修复: 你可以使用一个 key 来让 React 知道每个 hackathon.id 是一个不同的、唯一的组件:

<Temp2 key={hackathon.id} hackathon={hackathon} />

React 将不同的 key 视为不同的组件。当 hackathon.id 变化时,React 会卸载旧的 Temp2 实例并挂载一个新的。新的挂载意味着全新的状态,因此 useState 初始化器会再次运行,返回空数组。

这样,你可以这样做:

// ✅ 良好:让 React 通过 key 处理重置
function Temp2({ hackathon }{
  const [judgeDetailsCache, setJudgeDetailsCache] = useState([]);
  const [panelAssignmentsCache, setPanelAssignmentsCache] = useState([]);

  // 不需要手动重置!当 key 变化时状态会重新开始

  useEffect(() => {
    fetchJudgeDetails(hackathon.id).then(setJudgeDetailsCache);
  }, [hackathon.id]);

  return (
    <div>
      {judgeDetailsCache.map(judge => <div key={judge.id}>{judge.name}</div>)}
    </div>

  );
}

⚠️ 注意事项:key 方法会重新挂载整个组件,这意味着:

  • 所有状态都会被重置(不仅仅是缓存)
  • 所有效果都会重新运行(包括数据获取)
  • DOM 节点会被销毁并重新创建 当 prop 从根本上改变组件的身份时,这通常是你想要的。如果你只需要重置一些状态同时保留其他状态,那么 useEffect 可能仍然是合适的。

类别 3:未能清理当 useEffect 用于订阅外部系统(如 API、数据库、WebSocket 连接)时,未能正确处理外部系统交互的清理会导致内存泄漏和意外行为。

错误 9:未能取消获取请求

在执行数据获取时,如果组件在请求仍在挂起时卸载,未能取消请求可能导致陈旧的获取请求。

要解决此问题,必须使用 AbortController 并返回一个清理函数来中止请求:

useEffect(() => {
    const controller = new AbortController();
    fetch(URL, { signal: controller.signal })
      .then(res => res.json())
      .then(data => {
        // 处理响应数据
      })
      .catch(err => {
        if (err.name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          // 处理其他错误
        }
      });

    return () => {
      controller.abort(); // 清理函数在卸载/重新渲染时取消请求
    };
  },
  [ /* 依赖项 */ ]
);

错误 10:在请求完成后设置状态

即使使用了 AbortController,如果你在请求完成时更新状态(例如将 loading 设置为 false),在响应被处理之前组件刚好卸载,你仍然可能面临内存泄漏的风险。

要解决此问题,在清理函数中管理一个可变标志,如 isMounted

useEffect(() => {
  let isMounted = true;
  fetch(`/api/data`)
    .then(res => res.json())
    .then(data => {
      if (isMounted) { // 检查组件是否仍挂载
        setData(data);
        setLoading(false);
      }
    })
    .catch(error => {
      if (isMounted) {
        setError(error);
        setLoading(false);
      }
    });

  return () => {
    isMounted = false// 清理时设置为 false
    // ... 也可以在这里添加 abort controller 逻辑
  };
}, [/* 依赖项 */]);

这样,在调用任何 setState 之前,我们会检查以确保组件仍然挂载。

错误 11:未能移除事件监听器

假设你的效果添加了一个外部事件监听器(例如,监听 window 或 document)。那么你必须在清理函数中移除它,以防止内存泄漏并在整个应用程序中引起意外的副作用:

useEffect(() => {
  const handleResize = () => {
    // 处理调整大小
  };
  window.addEventListener('resize', handleResize);

  return () => {
    // 确保在卸载时移除事件监听器
    window.removeEventListener('resize', handleResize);
  };
}, []); // 注意:如果 onResize 在组件内定义且依赖变化,可能需要将其包含在依赖项中或使用 useCallback

类别 4:将 useEffect 用于其本不该被使用的场景

错误 12:使用 useEffect 处理特定事件的逻辑

如果你使用 useEffect 来监视由用户操作触发的状态变化(例如,当商品被添加到购物车时显示通知),那么你就是在误用这个 Hook:

// ❌ 避免:在效果内部放置特定事件的逻辑
useEffect(() => {
  if (product.isInCart) {
    showNotification(`Added ${product.name} to the shopping cart!`);
  }
}, [product.isInCart]);

此逻辑应直接放在触发购物车更改的事件处理程序函数中。

错误 13:过度使用 effect 初始化库

如果你初始化整个第三方库或服务,而这些库或服务应该只在每次应用加载时运行一次,那么通过 useEffect 在每个组件中放置该逻辑是低效的:

// ❌ 避免:在组件效果中初始化应用级别的服务。
useEffect(() => {
  initFacebookPixel();
  mermaid.initialize({...});
}, []);

相反,这种全局初始化应该移到一个单一的高级组件中,例如 App.tsx,并用一个标志包裹以确保它在每个会话中只运行一次:

// ✅ 修复:在 App 级别初始化一次,并在初始化前检查
// App.tsx
useEffect(() => {
  if(!isAppInitialized) { // isAppInitialized 应该是一个单例标志(例如在模块作用域内)
    initFacebookPixel();
    mermaid.initialize({...});
    isAppInitialized = true;
  }
}, []);

错误 14:当 useEffect 不是适合这项工作的 Hook 时

请记住,useEffect 在浏览器绘制之后运行。如果你正在处理 DOM 操作或测量,例如计算工具提示的位置,你需要在可见的绘制步骤之前运行逻辑以避免闪烁。

在这些特定场景中,使用 useLayoutEffect 才是正确的方法。useLayoutEffect 是 useEffect 的一个版本,它在浏览器重新绘制屏幕之前触发。

类似地,像 useSyncExternalStore 这样的 Hook 是专门为处理外部存储而设计的。这个 Hook 比 useEffect 更好,因为 useSyncExternalStore 确保订阅回调只触发一次。

这些专门的 Hook 通常比 useEffect 更好。

错误 15:使用 useEffectEvent 处理非响应式逻辑 (React 19.2+)

对于像跟踪分析这样的复杂场景,你需要读取最新的 props/state 值而不强制效果在每次这些值变化时重新运行,React 引入了 useEffectEvent

useEffectEvent Hook 让你可以将非响应式逻辑从你的 Effects 中提取到一个可重用的函数中,称为 Effect Event。

通过将跟踪逻辑包装在 useEffectEvent 中,你可以确保事件处理程序函数始终读取最新的 props(如 step 和 enhancedMetadata),而无需将它们添加到效果的依赖项数组中。这可以防止不必要的重新运行,同时保持数据完整性。

结论修复这些常见错误将带来更干净、更快、更易于维护的 React 应用。虽然数据获取库为许多与 useEffect 相关的清理和缓存问题提供了解决方案,但理解该 Hook 的核心规则对于掌握 React 开发至关重要。

尽管 useEffect 是一个常被误用的 Hook,但帮助团队理解其常见陷阱可以防止错误并改进现有代码。开发领导者可以利用这些见解来指导他们的团队,培养更好的编码实践,并确保 Hooks 在项目中得到有效使用。


号外~号外~

最近我们推出了大厂的一手面经模块,都是刚面完的小伙伴们热乎乎分享的:

  • 字节、阿里、腾讯最新面试真题
  • 面试流程和注意事项
  • 面试官的重点提问和考察点

这些面经都是花了不少心思整理的,比网上那些过时的八股文靠谱多了。

有需要的小伙伴可以点击这里👉前端面试题宝典打开小程序,首页即可直接领取【大厂真实面经】),也可直接联系小助手咨询。

毕竟信息差就是竞争力,早点了解面试套路,早点拿到心仪offer!

有会员购买、辅导咨询的小伙伴,可以通过下面的二维码,联系我们的小助手。

Image