你刷新页面,屏幕却是一片空白。或者点击一个按钮,你的 CPU 风扇就开始疯狂转动。接着,控制台弹出了那个令人恐惧的警告:Maximum update depth exceeded. React 紧急刹车了,因为你的组件陷入了死循环。这是 React 应用中最常见的错误之一,通常源于对代码实际执行时机的简单误解。
要修复这个问题,你需要理解渲染(render)何时变成重新渲染(re-render),以及为什么状态(state)变更必须避开渲染路径。
React 如何渲染你的组件
React 通过组件构建用户界面。在现代 React 中,这些组件都是函数。每当 React 需要在屏幕上显示你的组件时,它只需调用该函数即可。在函数内部,你可以使用 state 来在渲染之间记住某些信息。State 告诉 React 哪些数据属于该组件,并且至关重要的一点是,它能告知何时发生了变化以及 UI 何时需要更新。
当 state 发生变化时,React 会调度一次新的渲染。组件函数会再次运行,返回新的 JSX,然后 React 更新 DOM 以保持一致。在正常使用情况下,这个循环是无害的。你点击一个按钮,事件处理器更新 state,React 重新渲染一次,用户就能看到新的文本或颜色。
当渲染本身触发了另一个 state 更新时,错误就会出现。那个新的 state 更新又触发了另一次渲染,进而触发了又一次 state 更新。React 会容忍这种行为几十个周期,然后抛出最大深度错误(maximum depth error),以防止浏览器完全卡死。
快速了解 State
在剖析死循环之前,先回想一下 useState hook 的工作原理。它恰好为你提供两样东西:一个保存当前值的变量,以及一个用于更改该值的函数。
const MessageComponent = () => {
const [message, setMessage] = useState('Welcome');
return <h1>{message}</h1>;
};
在这里,第一次渲染时 message 的值是 'Welcome'。如果你稍后调用 setMessage('Goodbye'),React 会记录下这个变化,再次调用 MessageComponent,此时 UI 会显示 "Goodbye"。一切正常,因为组件体内部没有任何东西在自动调用 setter 函数。当 setter 在渲染阶段(render phase)由于非外部事件而触发时,死循环就开始了。
在组件体中直接调用 SetState
创建死循环最直接的方法就是在组件体内部直接调用 state setter 函数。因为组件体在每次渲染时都会执行,所以 setter 也会在每次渲染时触发。新的 state 更新会导致另一次渲染,循环往复,永无止境。
以下是错误的写法:
const Counter = () => {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
};
每次 Counter 渲染时,它都会增加 count。React 再次渲染以显示新数字,接着又看到了 setCount(count + 1),于是再次增加。修复方法很简单:永远不要在渲染期间在组件的顶层调用 state setter。State 更新应该响应用户事件或副作用,而不是响应“绘制屏幕”这一行为。请将该更新移至事件处理器中:
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
在渲染期间调用 setter 的唯一例外是当你需要根据 props 计算新的 state 时,但即便如此,你也应该使用不同的模式,例如直接推导(deriving)该值,或者有目的地使用 useEffect。
传递的是函数调用而非引用
另一个常见原因是细微的 JSX 拼写错误。当你绑定事件处理器时,你需要传递函数本身。如果你不小心在 JSX 中直接调用了该函数,它会在渲染周期内立即执行。
const Toggle = () => {
const [isOn, setIsOn] = useState(false);
const handleToggle = () => setIsOn(!isOn);
return <button onClick={handleToggle()}>Toggle</button>;
};
通过带括号的 handleToggle() 这种写法,你并不是在给 React 提供一个在用户点击时稍后调用的函数,而是在 React 构建虚拟 DOM 的当下就立即调用了它。由于 handleToggle 会更新 state,组件会重新渲染。在重新渲染期间,React 会再次看到 handleToggle() 并再次调用它。循环永不停止。正确的版本应该去掉括号:
return <button onClick={handleToggle}>Toggle</button>;
如果你需要传递参数,请将调用包装在一个匿名函数中:
return <button onClick={() => handleToggle(true)}>Switch On</button>;
这种区别在重构时甚至会让经验丰富的开发者也栽跟头。请务必留意那些括号。
useEffect 依赖陷阱
Effect 是处理副作用(如获取数据、与浏览器 API 同步或手动操作 DOM)的正确场所。但 useEffect 是在 React 将渲染提交到屏幕之后运行的。如果你的 effect 更新了 state,React 会重新渲染。通常情况下这没问题。但如果 effect 在每次渲染后都会运行,并且总是更新同一个 state,它就会变成一个死循环。
Consider this broken pattern:
const UserProfile = () => {
const [user, setUser] = useState({});
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
});
return <div>{user.name}</div>;
};
Because there is no dependency array, this effect runs after every single render. It sets user, which triggers a render. After that render, the effect runs again and sets user again. React detects the spiral and throws the error.
The fix is to tell React when the effect actually needs to run by supplying a proper dependency array. If the effect should only run once on mount, pass an empty array:
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
}, []);
If the effect depends on a prop or a piece of state, include only that variable in the array. Be careful, though. Including a variable that changes every render will just recreate the same loop through a different door. For example, if you include an object literal in the dependencies and that object is recreated on every parent render, the effect will fire endlessly. In those cases, you may need to move the object creation outside the component or memoize it.
Practical Debugging Steps
When you hit this error, the stack trace can look overwhelming because React has already repeated the cycle dozens of times. Start by reading the top of the trace to find which component is named repeatedly. Then look for state setters in these three places:
- The main body of the component, outside any handler or hook.
- JSX event attributes where you might have written
handler()instead ofhandler. useEffecthooks that lack a dependency array or depend on unstable references.
Temporarily comment out each state setter until the error stops. That tells you exactly which update is the culprit. If the setter is inside an effect, ask yourself whether you even need state there. Sometimes developers set local state from props inside an effect when they could simply use the prop directly in the JSX.
The Real Takeaway
The Maximum update depth error is not a mysterious React bug. It is a safety net. It means your component is trying to re-render itself instead of waiting for an external signal. Break the habit of treating renders as events that should produce more state. Treat renders as pure consequences of state, not as causes. Keep state updates inside event handlers, callbacks, or effects with carefully chosen dependencies, and you will never see this error again.
