You refresh the page and the screen stays blank. Or maybe a button click sends your CPU fan into overdrive. Then the console spits out the dreaded warning: Maximum update depth exceeded. React has slammed the brakes because your component is trapped in an infinite loop. It is one of the most common errors in React applications, and it usually stems from a simple misunderstanding about when your code actually runs.
To fix it, you need to understand the exact moment a render becomes a re-render, and why state changes must stay out of the render path.
How React Renders Your Component
React builds user interfaces out of components. In modern React, those components are functions. Every time React needs to show your component on the screen, it simply calls that function. Inside the function, you can use state to remember things between renders. State tells React which data belongs to the component and, crucially, when something has changed and the UI needs updating.
When state changes, React schedules a new render. The component function runs again, returns new JSX, and React updates the DOM to match. In normal usage, this cycle is harmless. You click a button, an event handler updates state, React re-renders once, and the user sees the new text or color.
The error appears when a render itself triggers another state update. That new state update triggers another render, which triggers another state update. React tolerates this for a few dozen cycles, then throws the maximum depth error to protect the browser from freezing entirely.
A Quick Look at State
Before dissecting the loop, recall how the useState hook works. It gives you exactly two things: a variable holding the current value, and a function to change that value.
const MessageComponent = () => {
const [message, setMessage] = useState('Welcome');
return <h1>{message}</h1>;
};
Here, message is 'Welcome' on the first render. If you later call setMessage('Goodbye'), React notes the change, calls MessageComponent again, and the UI now shows "Goodbye." Everything is fine because nothing in the component body itself is calling the setter automatically. The loop begins when the setter fires during the render phase without an external event.
Calling SetState Directly in the Body
The most direct way to create an infinite loop is to call a state setter function straight inside the component body. Because the component body executes on every render, the setter fires on every render. That new state update causes another render. The cycle spins forever.
Here is what the mistake looks like:
const Counter = () => {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
};
Every time Counter renders, it increments count. React renders again to show the new number, sees setCount(count + 1) again, and increments once more. The fix is straightforward: never call a state setter at the top level of your component during render. State updates should respond to user events or side effects, not to the act of painting the screen. Move that update into an event handler instead:
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
The only exception to calling setters during render is when you are computing new state from props, and even then you should use a different pattern, such as deriving the value directly or using useEffect intentionally.
Passing a Function Call Instead of a Reference
Another frequent cause is a subtle JSX typo. When you attach an event handler, you need to pass the function itself. If you accidentally invoke the function right there in the JSX, it runs immediately during the render cycle.
const Toggle = () => {
const [isOn, setIsOn] = useState(false);
const handleToggle = () => setIsOn(!isOn);
return <button onClick={handleToggle()}>Toggle</button>;
};
By writing handleToggle() with parentheses, you are not giving React a function to call later when the user clicks. You are calling it right now while React is building the virtual DOM. Since handleToggle updates state, the component re-renders. During that re-render, React sees handleToggle() again and calls it again. The loop never ends. The correct version removes the parentheses:
return <button onClick={handleToggle}>Toggle</button>;
If you need to pass arguments, wrap the call in an anonymous function:
return <button onClick={() => handleToggle(true)}>Switch On</button>;
This distinction trips up even experienced developers during refactoring. Keep an eye on those parentheses.
The useEffect Dependency Trap
Effects are the right place for side effects like fetching data, syncing with browser APIs, or manually manipulating the DOM. But useEffect runs after React commits the render to the screen. If your effect updates state, React will re-render. Normally that is fine. It becomes a loop when the effect runs after every render and always updates the same state.
Considere este padrão problemático:
const UserProfile = () => {
const [user, setUser] = useState({});
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
});
return <div>{user.name}</div>;
};
Como não há um array de dependências, este efeito é executado após cada renderização. Ele define user, o que aciona uma nova renderização. Após essa renderização, o efeito é executado novamente e define user de novo. O React detecta o ciclo infinito e lança o erro.
A solução é informar ao React quando o efeito realmente precisa ser executado, fornecendo um array de dependências adequado. Se o efeito deve ser executado apenas uma vez na montagem, passe um array vazio:
useEffect(() => {
setUser({ name: 'Ada', role: 'Admin' });
}, []);
Se o efeito depender de uma prop ou de um estado, inclua apenas essa variável no array. Mas tenha cuidado. Incluir uma variável que muda a cada renderização apenas recriará o mesmo loop por uma porta diferente. Por exemplo, se você incluir um objeto literal nas dependências e esse objeto for recriado a cada renderização do componente pai, o efeito será disparado infinitamente. Nesses casos, você pode precisar mover a criação do objeto para fora do componente ou memorizá-lo.
Passos Práticos de Depuração
Quando você encontrar esse erro, o stack trace pode parecer esmagador porque o React já repetiu o ciclo dezenas de vezes. Comece lendo o topo do trace para encontrar qual componente é mencionado repetidamente. Em seguida, procure por setters de estado nestes três lugares:
- O corpo principal do componente, fora de qualquer handler ou hook.
- Atributos de evento JSX onde você pode ter escrito
handler()em vez dehandler. - Hooks
useEffectque não possuem um array de dependências ou que dependem de referências instáveis.
Comente temporariamente cada setter de estado até que o erro pare. Isso dirá exatamente qual atualização é a culpada. Se o setter estiver dentro de um efeito, pergunte-se se você realmente precisa de estado ali. Às vezes, desenvolvedores definem um estado local a partir de props dentro de um efeito quando poderiam simplesmente usar a prop diretamente no JSX.
A Principal Lição
O erro "Maximum update depth exceeded" não é um bug misterioso do React. É uma rede de segurança. Significa que seu componente está tentando renderizar a si mesmo novamente em vez de esperar por um sinal externo. Quebre o hábito de tratar renderizações como eventos que devem produzir mais estado. Trate as renderizações como consequências puras do estado, não como causas. Mantenha as atualizações de estado dentro de event handlers, callbacks ou efeitos com dependências cuidadosamente escolhidas, e você nunca mais verá esse erro.
