12k
All articles

如何修复 React 中的 “Maximum Update Depth Exceeded”

通过一行修复解决 React 的 maximum update depth exceeded 错误,涵盖渲染循环、effect 依赖、处理器和节流。

OpenReplay Team
OpenReplay Team
如何修复 React 中的 “Maximum Update Depth Exceeded”

“Maximum update depth exceeded”(超出最大更新深度)意味着你的组件陷入了无限渲染循环:一次状态更新触发了重新渲染,而重新渲染又再次触发同一次状态更新,React 在嵌套更新超过 50 次(即其 NESTED_UPDATE_LIMIT)后中止执行,以防浏览器卡死。

如果你曾眼睁睁看着标签页卡住、控制台里同一行红色报错不断堆叠,你就知道那一大片文字一开始有多让人无从下手。好消息是,一旦你识别出自己踩中的是哪种模式,修复几乎总是一行代码的事。

本文覆盖了完整的成因集合,包括高频事件处理器这一场景,每种成因都配有可直接复制粘贴的修改前/修改后对照、一张帮你快速定位的症状查询表,以及能在一分钟内找到问题 setter 的工具方法。函数组件和类组件的根本原因是一致的:一个永远无法收敛的更新循环。

关键要点

  • 该错误本质是无限渲染循环;当嵌套更新次数超过 50 次(reconciler 源码中的 NESTED_UPDATE_LIMIT)时,React 会将其终止。
  • onClick={handleClick()} 会在渲染期间调用该函数,并在每次渲染时都调度一次状态更新。应传入 onClick={handleClick},需要传参时则使用 onClick={() => handleClick(id)}
  • 复用性最强的单一修复手段是函数式更新(setCount(prev => prev + 1)),它让你可以把该状态从 effect 的依赖数组中移除,从而打破“先读后写”的循环。
  • useCallback 稳定的是函数的引用标识,而不是它的执行频率,因此它无法修复由 onScroll 或 dnd-kit 的 onDragMove 等高频处理器引发的循环;正确做法是对状态更新做节流(throttle)或防抖(debounce)。
  • 类组件的 #185 错误在开发和生产环境中都会抛出,但 useEffect 版本仅是开发环境的警告。在生产环境中,该循环会照常运行,既不抛错也没有任何控制台提示。

症状 → 成因 → 修复速查表

先找到与你所见相符的症状,再跳转到对应的修复方案。

症状成因修复
无需点击就出现循环,挂载时即开始onClick={fn()} 在渲染期间调用了 setter改为传入 onClick={fn}
组件顶层直接调用 setState在渲染路径中执行状态更新移入事件处理器或 effect 中
Effect 每次渲染都执行依赖缺失/有误,或依赖中含内联对象/数组修正依赖 + useMemo/useCallback
Effect 读取并写入同一个状态该状态出现在自己的依赖中使用函数式更新;移除该依赖
仅在拖拽/滚动/缩放时出现循环每次高频事件都调用 setState对更新做节流/防抖
父子组件互相覆盖双向状态同步改为单向数据流

修复 1 和 2:处理器引用与渲染路径中的 setState

见效最快的两点在于:你如何绑定事件处理器,以及你在哪里调用 setter。onClick={handleClick()} 会在渲染期间调用函数,并在每次渲染时调度一次状态更新;你几乎总是应该写成 onClick={handleClick},需要传参时则写成 onClick={() => handleClick(id)}

// BAD: acceptTerms runs during render, every render
<input type="checkbox" onChange={acceptTerms()} />

// GOOD: pass the reference; wrap in an arrow to pass args
<input type="checkbox" onChange={acceptTerms} />
<button onClick={() => selectItem(item.id)}>Select</button>

在组件函数体中直接调用 setter 也会造成同样的循环。状态更新应当放在事件处理器或 effect 中,绝不能放在渲染路径里。

// BAD: runs on every render → loop
function Counter() {
  const [count, setCount] = useState(0);
  setCount(count + 1);
  return <div>{count}</div>;
}

// GOOD: update in response to an event
const increment = () => setCount(c => c + 1);

修复 3、4 和 5:effect 依赖循环

大多数 effect 循环源自引用标识不断变化的依赖,或者 effect 写入了它自己读取的状态。直接内联写在依赖数组中的对象或数组字面量,每次渲染都会生成新的引用标识,导致 effect 每次渲染都重新执行;应使用 useMemo(对象/数组)或 useCallback(函数)包裹,以保持引用稳定。

// BAD: options is a new object each render → effect re-runs forever
const options = { limit: 10, sort: 'date' };
useEffect(() => { search(query, options).then(setResults); }, [query, options]);

// GOOD: memoize so the reference is stable
const options = useMemo(() => ({ limit: 10, sort: 'date' }), []);

复用性最强的单一修复手段是函数式更新。当一个 effect 既读取又写入同一个状态时,setCount(prev => prev + 1) 会从更新函数的参数而非闭包中读取上一次的值,这让你可以将该状态从依赖数组中移除,从而打破循环。

// BAD: count is read and written, and it's in deps
useEffect(() => { setCount(count + 1); }, [count]);

// GOOD: functional updater removes the dependency
useEffect(() => { setCount(prev => prev + 1); }, []);

对于 effect 所依赖的函数,要么用 useCallback 配合正确的依赖将其包裹,要么把它移到 effect 内部:在 effect 内部声明的函数每次执行时创建一次,无需作为依赖项。

修复 6:对高频处理器做节流

useCallback 稳定的是函数在多次渲染之间的引用标识,但它并不改变函数的执行频率,因此它无法修复由 onScrollonMouseMoveonResize 或 dnd-kit 的 onDragMove 等高频处理器引发的无限循环。这类事件每秒会触发数十次,而每一次 setState 都会调度一次新的渲染。正确做法是对状态更新做节流或防抖。

// BAD: fires dozens of times/sec while dragging
const handleDragMove = (event) => setDragPreview(compute(event));

// GOOD: cap the update rate; useCallback alone won't help
import { throttle } from 'lodash';
const handleDragMove = throttle((event) => setDragPreview(compute(event)), 100);

lodash 的 throttle 会限制被包裹函数在给定时间窗口内的触发次数;原生的 requestAnimationFrame 或防抖同样可行。关键在于控制频率,而非稳定引用。

修复 7:父子组件同步循环

双向状态传播(子组件的 effect 调用父组件的 setter,导致子组件重新渲染,进而再次触发该 effect)是另一种典型循环。应将状态提升到单一所有者,保持数据流单向;或者在父组件中直接转换该值,而不是通过 effect 把它同步回去。

快速定位问题(以及类组件的情形)

自上而下排查:先阅读堆栈跟踪,找到循环顶端那个具名的 setter,然后打开 React DevTools 的 Profiler,找出那个不停重新渲染的组件。可以引入 why-did-you-render(已针对 React 19 测试,仅限开发环境,尚未在 React Compiler 下验证)来查看是哪个 prop 或 state 的引用标识发生了变化。更好的做法是在 lint 阶段就拦截这些问题:从 eslint-plugin-react-hooks 7.x 起,该插件提供了专门的 set-state-in-renderset-state-in-effect 规则,能在你运行应用之前捕获这两种最常见的触发方式。二者都包含在默认的 recommended 预设中,因此只要升级插件即可启用;recommended-latest 只是在此基础上额外叠加了实验性的 compiler 规则。当组件在渲染期间设置状态且没有任何条件守卫时,set-state-in-render 就会报错——而这正是会演变成循环的代码形态。

无论是函数组件还是类组件,该错误的根本原因都相同;在类组件中,它通常意味着在渲染期间调用了 setState,或者在 componentDidUpdate 中无条件地调用了它。还要留意运行环境的差异:类组件的 #185 错误在开发和生产环境中都会抛出,但 useEffect 版本仅是开发环境的警告。在 reconciler 源码中,嵌套 passive update 的检查位于一个仅开发环境生效的守卫内,并且是向控制台输出日志而非抛出异常。因此生产构建会照常运行那个 effect 循环:不抛错、不触发错误边界、没有任何控制台信号,最终只表现为标签页卡死。

正是这一生产环境的盲区,让此类循环的调试成本变得高昂。控制台显示了错误,却没有告诉你是哪一次交互引发了它;而对于 effect 循环,控制台甚至可能什么都不显示。像 OpenReplay 这样的会话回放工具会在错误出现时捕获控制台错误,并同时记录此前的用户操作序列,于是一段光秃秃的堆栈跟踪(或一次无声的卡死)就变成了可复现、可逐次点击回放的案例,你可以据此对照上文的修复方案逐一验证。

一旦你把自己的症状对应到表中的某一行,改动通常只有一行:把调用换成引用、给对象套上 useMemo、或者用函数式更新去掉一个依赖。配置好 exhaustive-deps 以及较新的 set-state-in-* 规则,让下一次循环终止在你的 lint 环节,而不是用户的浏览器里。

常见问题

该错误的 useEffect 版本与 React 错误 #185 有什么区别?

它们是两条不同的错误信息,运行时行为也不同。错误 #185 是类组件的表述,提到在 componentWillUpdate 或 componentDidUpdate 中调用 setState,并且在开发和生产环境中都会抛出。useEffect 版本则是 reconciler 源码中一个独立的、仅限开发环境的警告,被包裹在仅开发环境生效的守卫之中,因此在生产环境中该 effect 循环会照常运行,不抛错、不触发错误边界,也没有任何控制台信号。

为什么加上 useCallback 也无法阻止拖拽或滚动时的无限循环?

因为 useCallback 稳定的是函数在多次渲染之间的引用标识,并不改变该函数的执行频率。像 onScroll、onMouseMove 或 dnd-kit 的 onDragMove 这类高频处理器每秒会触发数十次,无论函数引用是否被记忆化,每一次 setState 都会调度一次新的渲染。正确的修复方式是控制频率:使用 lodash 的 throttle、防抖或 requestAnimationFrame 对状态更新本身做节流或防抖。

使用函数式更新形式后,是否总能把状态从 effect 的依赖数组中移除?

只有当 effect 对该状态的唯一需求是计算下一个值时才可以。写成 setCount(prev => prev + 1) 会从更新函数的参数而非闭包中读取上一次的值,因此 count 可以离开依赖数组,先读后写的循环随之打破。但如果该 effect 还会为其他逻辑读取这个状态,比如用于条件分支或传给另一个函数,那你仍然需要把它作为依赖,并通过其他方式打破循环。

为什么错误只在开发环境出现,而生产构建只是静默卡死?

因为 useEffect 版本的嵌套 passive update 守卫在 React reconciler 中被包裹在仅开发环境生效的检查里,所以它只在开发期间输出控制台警告。在生产构建中该守卫不会执行,这意味着同样的 effect 循环会在不抛出错误、不输出控制台信息的情况下运行,最终只表现为标签页卡死或渲染失控。类组件的 #185 错误则不同,它在两种环境下都会抛出。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.