如何修复 React 中的 “Maximum Update Depth Exceeded”
通过一行修复解决 React 的 maximum update depth exceeded 错误,涵盖渲染循环、effect 依赖、处理器和节流。
“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 | 对更新做节流/防抖 |
| 父子组件互相覆盖 | 双向状态同步 | 改为单向数据流 |
Discover how at OpenReplay.com.
修复 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 稳定的是函数在多次渲染之间的引用标识,但它并不改变函数的执行频率,因此它无法修复由 onScroll、onMouseMove、onResize 或 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-render 和 set-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 错误则不同,它在两种环境下都会抛出。
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