首页 / 文章

audit useEffect:十大常见错误、症状及真正解决方案

识别 React 中最常见的十种 useEffect 错误,包括遗漏清理函数、过时的闭包问题、竞态条件以及属性同步问题,并采取正确的修复措施。

发布者:REACTAPP.TOP阅读约 8 分钟1919 词

来源: 10 React useEffect Mistakes That Are Secretly Breaking Your App in 2026

React 19、React 编译器以及服务器组件将许多功能从 useEffect 中移出,但这个钩子依然是众多棘手 React 错误的滋生地:内存泄漏、数据过时、界面闪烁以及渲染循环。这些问题大多源于十种常见的错误。下文会针对每种错误分别说明出错代码、症状及解决方案,最后还会提供检查自己编写的 useEffect 代码的清单。

所有解决方案背后的原则是:useEffect 的存在是为了让组件与 React 之外的元素保持同步,比如定时器、套接字或浏览器 API。

1. 忘记编写清理函数

如果在 useEffect 中创建了定时器、订阅项或事件监听器,除非主动将其销毁,否则这些元素会在组件重新渲染或卸载后继续运行。例如,下面的代码中的定时器就永远不会被清除:

// ❌ Broken: No cleanup
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  // Missing: return () => clearInterval(timer);
}, []);

其表现为间隔时间泄漏以及监听器重复注册。在开发模式下,严格模式会刻意地安装、卸载并重新安装组件以暴露这一问题:如果不进行清理,就会出现两个间隔定时器,计数器每秒会递增两次。在生产环境中,只要组件被重新安装,就会出现这种泄漏现象。应返回一个用于撤销上述设置的函数:

// ✅ Fixed: Always clean up
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(timer);
}, []);

2. 使用空依赖数组表示“仅运行一次”

空数组并不具有任何特殊含义,表示“在组件安装时执行”。它只是告诉React该效应不依赖任何内容,因此永远不会重新运行。如果该效应读取了属性或状态,这些值将保持首次渲染时的状态不变:

// ❌ Broken: Stale closure
function ChatRoom({ roomId }) {
  useEffect(() => {
    connectToRoom(roomId);
    return () => disconnectFromRoom(roomId);
  }, []); // roomId changes, but effect never re-runs
}

当 roomId 发生变化时,组件仍会留在原来的房间中。列出该效应所使用的所有响应式值;react-hooks/exhaustive-deps 检查规则可以检测到这一问题。

// ✅ Fixed: Include real dependencies
useEffect(() => {
  connectToRoom(roomId);
  return () => disconnectFromRoom(roomId);
}, [roomId]);

现在,每次 roomId 变化时,React 都会断开与之前房间的连接并连接到新的房间。

3. 依赖每次渲染都会重新创建的对象

依赖项是通过引用进行比较的。在渲染过程中创建的对象或数组字面量每次都是新的引用,因此依赖它的效应会在每次渲染后重新执行:

// ❌ Broken: New object every render
function Parent() {
  const config = { timeout: 5000 }; // New reference every time

  useEffect(() => {
    fetchData(config);
  }, [config]); // Runs every single render
}

请稳定该引用。如果其值永不改变,应在组件外部定义一次。若该值由属性或状态生成,则需用带有正确依赖项的 useMemo 将其包裹起来。下方的代码片段并列展示了这两种方法;实际使用时只需选择其中一种即可:

// ✅ Fixed: Stable reference
const CONFIG = { timeout: 5000 }; // Defined outside component
function Parent() {
  const config = useMemo(() => ({ timeout: 5000 }), []);

  useEffect(() => {
    fetchData(config);
  }, [config]);
}

通常还有更简单的方法:直接使用原始值(如 timeout 而非 config),并在效应函数内部构建对象。React 编译器会对其编译的代码中的许多值进行缓存,但对于来自未编译代码或库的对象则不会,因此不可依赖它来保证效应函数的正确性。

4. 允许异步响应同时执行

当依赖项变化迅速时,比如在搜索框使用或快速切换标签页的情况下,可能会同时有若干请求正在处理中,而且这些请求并不一定按顺序完成:

// ❌ Broken: Race condition
useEffect(() => {
  fetch(`/api/search?q=${query}`).then(res => res.json()).then(setResults);
}, [query]);

如果用户先输入“react”,再输入“react hooks”,针对“react”的响应可能会最后到达并覆盖正确的结果。解决办法是在清理函数中取消过时的请求。AbortController可用于中止获取操作,而由此产生的AbortError会被忽略,因此不会被视作真正的失败:

// ✅ Fixed: Abort in-flight requests
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

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

对于搜索界面而言,还存在更多细微问题,相关内容可在解决防抖无法处理的竞态条件一文中找到。

5. 将派生值存储在状态中

如果某个值可以从现有的属性或状态中计算得出,那么就不需要为其单独设置状态或使用效应函数:

// ❌ Broken: Redundant state + effect
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

每次更改都会被渲染两次,第一次使用过时的fullName值,这样就出现了两个数据来源。应在渲染时计算该值,仅将useMemo用于真正耗时的操作:

// ✅ Fixed: Compute during render
const fullName = `${firstName} ${lastName}`;// Or for expensive computations
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

请注意,示例中展示了两种方案;在同一个作用域内重复声明fullName会引发语法错误,因此只需保留一个。更通用的处理方式可在使用useEffect同步状态的更安全替代方案中了解。

6. 创建更新循环

会设置状态的效果函数也会永远依赖于自身的调度机制:

// ❌ Broken: Infinite loop
useEffect(() => {
  setCount(count + 1);
}, [count]);

更新会触发渲染,渲染会改变count的值,变化后的count又会重新执行该效果,如此循环往复。最终React会因“最大更新深度已超出”错误而停止这一过程。使用函数式更新后无需在效果函数中读取count,因此它也不再需要作为依赖项:

// ✅ Fixed: Functional update + correct dependencies
useEffect(() => {
  const timer = setTimeout(() => {
    setCount(c => c + 1); // Functional update avoids dependency
  }, 1000);
  return () => clearTimeout(timer);
}, []); // No count dependency needed

请注意,这会改变程序行为:计数器现在会在组件挂载一秒后才递增一次。对于需要持续计数的情况,应像错误1中所做的那样使用带有清理功能的setInterval。在效果函数中使用的任何setState操作都需要有能确保其最终稳定的条件。

7. 完全省略依赖项数组

如果没有第二个参数,效果函数将在每次渲染后都执行。有时这正是你想要的,但通常这是意外情况且效率较低:

// ❌ Broken: Runs on every render
useEffect(() => {
  console.log('Effect running');
  performHeavyComputation(data);
});

React编译器可以减少不必要的重新渲染,但它无法降低effect函数内部操作的成本,因此那些执行繁重任务的未经保护的effect仍然会成为性能瓶颈。请明确指定实际的依赖项:

// ✅ Fixed: Explicit dependencies
useEffect(() => {
  performHeavyComputation(data);
}, [data]);

8. 在effect中处理用户事件

Effect函数会在有内容被渲染时执行,而非因为用户进行了操作。与状态标志相关的分析功能会在该标志因任何原因发生变化时立即触发:

// ❌ Broken: Event logic in effect
useEffect(() => {
  if (isModalOpen) {
    trackEvent('modal_opened');
  }
}, [isModalOpen]);

只要模态框处于打开状态、父组件更改了属性或状态被恢复,就会记录此事件,而不仅限于用户点击时。应将交互逻辑放在执行实际交互的操作处理函数中:

// ✅ Fixed: Event handler
function handleOpenModal() {
  setIsModalOpen(true);
  trackEvent('modal_opened');
}

如果能够明确指定触发代码的用户操作,那么该逻辑就应该放在相应的处理函数中。

9. 使用纯effect获取数据

在许多应用中,服务器组件和框架加载器已负责处理初始数据,因此较少需要在客户端通过 effects 进行数据获取。即便在客户端进行数据获取,原始的 effect 也存在诸多缺陷:

// ❌ Broken: Raw fetch in effect
useEffect(() => {
  fetch('/api/user').then(r => r.json()).then(setUser);
}, []);

它没有加载状态、错误处理机制、请求去重功能、缓存能力,而且如错误示例4所示,也没有防止竞态条件的保护措施。而数据获取库能够处理所有这些问题:

// ✅ Fixed: Use a data-fetching library
const { data: user, isLoading, error } = useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000,
});

TanStack Query 和 SWR 是常见的选择。如果没有这些库,就需要自行编写一个包含加载状态、错误处理和取消功能的自定义钩子,而非临时创建各种 fetch effects。

10. 将属性复制到状态并保持同步

那种接收初始值、将其复制到状态,再通过 effect 反映属性变化的组件,实际上是在与自身矛盾:

// ❌ Broken: Prop-to-state sync
function EmailInput({ initialEmail }) {
  const [email, setEmail] = useState(initialEmail);

  useEffect(() => {
    setEmail(initialEmail); // Sync prop to state
  }, [initialEmail]);

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

每当父组件提供新的initialEmail值时,会在使用旧值额外渲染一次之后,悄悄覆盖用户的输入内容。请选择一种处理方式:要么让该组件完全受控,要么保持其非受控状态,并通过更改其key来有意重置它,这样React就会获得一个具有新状态的实例。

// ✅ Fixed: Controlled or fully uncontrolled
// Option 1: Fully controlled — no internal state
function EmailInput({ email, onChange }) {
  return <input value={email} onChange={onChange} />;
}
// Option 2: Fully uncontrolled with key
function EmailInput({ initialEmail }) {
  const [email, setEmail] = useState(initialEmail);
  return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Usage: Reset by changing key
<EmailInput key={userId} initialEmail={user.email} />

React 19如何改变这一状况

较新版本的React使得这类错误既更易被发现,又没那么必要了:

  • 严格模式仅在开发环境下会对效果函数进行双重挂载,从而能及早发现缺失或错误的清理逻辑。
  • React编译器会自动对组件及许多值进行缓存,这减少了意外重新渲染的情况,但无法解决效果逻辑的问题。
  • 服务器端组件承担了大部分初始数据加载工作,而效果处理则主要留给仅能在客户端执行的操作,如订阅、动画和浏览器 API。
  • use API 可以在渲染过程中读取承诺和上下文,从而减少因异步值而需要使用效果处理的情形。
  • 效果处理功能的合并前检查清单

    在发布效果功能之前,请检查:

    • 每个订阅、定时器及监听器都有对应的清理机制。
    • 依赖数组列出了该效果功能所读取的所有响应式值。
    • 对象和数组形式的依赖是稳定的,或已被原始类型依赖替代。
    • 当依赖发生变化时,异步操作会被取消或忽略。
    • 效果功能中计算的任何内容都无法在渲染过程中直接得出。
    • 如果没有停止条件,任何状态更新都不会重新触发同一个效果功能。
    • 由用户触发的逻辑位于事件处理函数中。
    • 数据获取尽可能使用服务器组件、框架加载器或数据获取库来完成。
    • 每个状态都只有一个唯一的真实来源。

    总结

    几乎所有的 useEffect 错误都是因为将该钩子用于非外部同步的场景。派生值应放在渲染函数中,用户操作应在处理函数中处理,服务器数据则应存放在数据层中,而可重置的状态则需通过 key 来管理。剩余的真正订阅项需要准确的依赖关系和清理机制。通常,审计后的最佳结果就是删除那些不必要的效果函数。