Maximum update depth exceeded: o loop de re-render
O erro que trava a aba: setState no render ou useEffect com dependência errada. As quatro causas, cada uma reproduzida com o rastro real do React 19.
Maximum update depth exceeded é o React desistindo. Alguma coisa no seu
componente pede um render novo toda vez que ele renderiza, e o ciclo não fecha.
O erro é o freio de emergência, não a doença — e a doença está sempre em uma de
quatro linhas possíveis.
Os exemplos deste artigo são o painel de uma clínica: a lista de consultas do dia, com paciente, horário e status. Tudo rodou num MacBook, no Node 24.16.0 com jsdom 30.0.1 e React 19.2.8 em modo de desenvolvimento. A saída logo abaixo de cada bloco é a saída real dessa execução.
O nome técnico do defeito é ciclo de atualização: renderizar provoca uma mudança de estado que provoca outro render, sem uma condição capaz de encerrar a sequência. Em palavras simples, o componente aciona de novo o mecanismo que acabou de acioná-lo.
O termostato que manda ligar toda vez que liga
Imagine um termostato defeituoso que, ao perceber o aquecedor ligado, envia
outro comando para ligar. Cada comando gera a condição que dispara o próximo,
e o sistema nunca chega ao estado estável. setState no render, efeito que
atualiza a própria dependência e handler executado no JSX criam versões desse
feedback no React.
Antes de corrigir o primeiro caso, desenhe uma seta para cada causa: render → setter → render. Depois marque onde deveria existir uma condição de parada. Conte os logs da execução e confirme que a sequência repete o mesmo caminho; na versão corrigida, a seta precisa terminar.
O React usa dois textos diferentes para o mesmo problema, e eles não valem a mesma coisa. Saber qual dos dois apareceu já corta o diagnóstico pela metade.
| o que aparece no console | de onde veio | o React para sozinho? |
|---|---|---|
Error: Maximum update depth exceeded. |
atualização síncrona encadeada no commit — useLayoutEffect ou ref de callback |
sim, ele lança e aborta a árvore |
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect… |
atualização encadeada num useEffect comum |
não — é só um aviso, o loop continua |
Error: Too many re-renders. |
setState chamado durante o próprio render |
sim, e antes de qualquer efeito rodar |
A primeira versão é a que dá nome ao artigo, e é a única lançada como erro de
verdade. Ela aparece quando a atualização encadeia dentro do próprio commit, e o
caso mais comum é o useLayoutEffect. Aqui, uma tentativa de medir a altura da
lista de espera e guardar no estado:
function AlturaDaAgenda() {
const lista = useRef(null);
const [altura, setAltura] = useState(0);
useLayoutEffect(() => {
setAltura(altura + 1);
});
return <ul ref={lista} style={{ height: altura }}><li>09:00 Marina</li></ul>;
}Repare na frase inside componentWillUpdate or componentDidUpdate. Você não
escreveu classe nenhuma: a mensagem é antiga e fala dos métodos de classe.
Traduzida para hooks, ela quer dizer efeito de layout — useLayoutEffect,
que roda de forma síncrona antes de o navegador pintar. Cada setState dele
empilha uma atualização em cima da outra, sem folga para o navegador respirar.
Um ref de callback escrito direto no JSX cai no mesmo buraco, e quase ninguém
liga um ao outro. Como a função é nova a cada render, o React desliga e religa a
ref durante o commit; um setState ali dentro encadeia igualzinho. Trocando o
useLayoutEffect do exemplo por ref={() => setAltura((a) => a + 1)}, sai a
mesma mensagem nos mesmos 53 renders.
A parte que assusta é o que sobra na tela depois:
setTimeout(() => {
console.log('conteudo de #root depois do erro:', JSON.stringify(document.getElementById('root').innerHTML));
process.exit(0);
}, 500);Tela branca. Quando o erro sobe sem nenhum error boundary no caminho, o React desmonta a raiz inteira. É por isso que esse bug costuma chegar como “a página sumiu” e não como “apareceu um erro”.
Causa 1: setState no corpo do componente
O render precisa ser puro: dados entram, JSX sai, e nada mais acontece. Chamar
um setState direto no corpo quebra essa regra e agenda um render novo dentro
do render que ainda está acontecendo.
const banco = [
{ id: 1, paciente: 'Marina', status: 'confirmada' },
{ id: 2, paciente: 'Otávio', status: 'cancelada' },
{ id: 3, paciente: 'Rita', status: 'confirmada' },
];
function Painel() {
const [confirmadas, setConfirmadas] = useState(0);
// parece inofensivo: só sincronizar o contador com a lista
setConfirmadas(banco.filter((c) => c.status === 'confirmada').length);
return <p>{confirmadas} de {banco.length} confirmadas</p>;
}Note que a mensagem não é a do título deste artigo. Loop que nasce dentro do
render nunca chega a virar Maximum update depth exceeded, porque o React aborta
antes de comitar qualquer coisa. Se você recebeu Too many re-renders, procure
por chamada de setState fora de evento e fora de efeito.
E o conserto aqui não é envolver em useEffect: é não guardar no estado o que dá
para calcular. confirmadas é derivado de banco, então é uma constante comum
no corpo da função. Quem quiser entender por que o componente roda de novo a cada
mudança de estado encontra o mecanismo completo em
re-render no React, e o comportamento do próprio
setter em useState.
Causa 2: useEffect sem array de dependências
Sem o segundo argumento, o efeito roda depois de todo render. Se ele mexe no estado, o próximo render dispara o efeito de novo, para sempre.
function Agenda() {
const [consultas, setConsultas] = useState([]);
useEffect(() => {
setConsultas([{ paciente: 'Marina', horario: '09:00' }]);
});
return <p>{consultas.length} consultas hoje</p>;
}Essa mensagem é a que a maioria das pessoas encontra — e é a mais traiçoeira, porque ela não interrompe nada. Chega no console como aviso, o React continua renderizando, e a aba vai esquentando. Dá para medir: basta contar renders e avisos por três segundos.
let renders = 0;
let avisos = 0;
const erroOriginal = console.error;
console.error = (...args) => {
if (String(args[0]).includes('Maximum update depth')) avisos++;
};
function Agenda() {
const [consultas, setConsultas] = useState([]);
renders++;
useEffect(() => {
setConsultas([{ paciente: 'Marina', horario: '09:00' }]);
});
return <p>{consultas.length} consultas hoje</p>;
}
createRoot(document.getElementById('root')).render(<Agenda />);
const inicio = Date.now();
setTimeout(() => {
erroOriginal(
`Em ${((Date.now() - inicio) / 1000).toFixed(1)} s: ${renders} renders e ${avisos} avisos do React.`
);
process.exit(0);
}, 3000);Setenta e três mil renders em três segundos, e nenhum deles é o último. Repetindo a medição, deu 73.752, 72.473 e 67.413. É esse volume que congela a aba: não existe erro fatal, existe uma thread ocupada até o navegador propor fechar a página.
O conserto é declarar o array de dependências, e quase sempre ele é [] quando
o efeito só precisa acontecer na montagem. O que cada posição desse array
significa está em useEffect.
Causa 3: um objeto novo na lista de dependências
Essa é a versão adulta da causa 2, e a que mais engana gente experiente. O array de dependências existe, está preenchido, e mesmo assim o efeito roda toda vez — porque o React compara as dependências por identidade, não por conteúdo.
function renderizar() {
return { status: 'confirmada' };
}
const primeiroRender = renderizar();
const segundoRender = renderizar();
console.log('parecem iguais? ', JSON.stringify(primeiroRender) === JSON.stringify(segundoRender));
console.log('são o mesmo objeto?', Object.is(primeiroRender, segundoRender));
console.log('e uma string? ', Object.is('confirmada', 'confirmada'));
console.log('e um array vazio? ', Object.is([], []));Dois objetos com exatamente o mesmo conteúdo são objetos diferentes. String é igual a string; objeto novo nunca é igual a objeto novo. Agora veja o que isso faz num componente que monta o filtro dentro do corpo:
function ListaDeConsultas() {
const [consultas, setConsultas] = useState([]);
const filtro = { status: 'confirmada' };
useEffect(() => {
setConsultas(banco.filter((c) => c.status === filtro.status));
}, [filtro]);
return <p>{consultas.length} consulta(s)</p>;
}O array de dependências está lá, e o efeito roda infinitamente do mesmo jeito. Vale para objeto, array, função declarada no corpo e para qualquer coisa criada durante o render. Quando o valor precisa mesmo ser um objeto, ele vira caso de useMemo e useCallback; quando não precisa, depender do campo primitivo é mais barato e mais legível.
Causa 4: onClick={confirmar()} em vez de onClick={confirmar}
Um par de parênteses a mais e o manipulador deixa de ser passado para ser executado — durante o render, que é exatamente o lugar proibido.
function Confirmacao() {
const [status, setStatus] = useState('pendente');
function confirmar() {
setStatus('confirmada');
}
return <button onClick={confirmar()}>Confirmar consulta ({status})</button>;
}Aqui aparece um detalhe que quase ninguém espera. O setStatus só muda o valor
na primeira vez: da segunda em diante ele já vale 'confirmada'. Você diria que
o React ia parar de renderizar, porque o estado não muda mais. Não para:
function confirmar() {
setStatus('pendente'); // mesmo valor que ja esta no estado
}Cinquenta e dois renders de novo, com o valor idêntico. A comparação que evita
render desnecessário só vale para setState chamado fora do render: aí o
React confere na hora se o valor novo é igual ao atual e nem agenda a
atualização. Chamado durante o render, o setState pula essa conferência e força
outra passagem de qualquer jeito. É por isso que o padrão de estado derivado só
funciona com uma guarda explícita (if (valorAtual !== valorNovo) setValor(valorNovo)).
Em quantos renders o React desiste
Os três limites estão escritos no próprio react-dom-client.development.js do
React 19.2.8: RE_RENDER_LIMIT = 25, NESTED_UPDATE_LIMIT = 50 e
NESTED_PASSIVE_UPDATE_LIMIT = 50. O que se mede na execução é maior que isso,
porque o React repete a passagem de render que falhou antes de desistir. No
setState no corpo dá para ver o mecanismo inteiro: imprimindo o estado lido a
cada chamada, sai 0,1,2,…,25 e depois 0,1,2,…,25 outra vez — a passagem tem
26 renders (o inicial mais os 25 do limite) e roda duas vezes. Daí os 52.
| caso | limite interno | chamadas do componente até o React reagir |
|---|---|---|
setState no corpo do render |
25 re-renders na mesma passagem | 52 |
setState em useLayoutEffect |
50 atualizações encadeadas | 53 |
setState em useEffect |
50 atualizações encadeadas | 53, e aí só avisa |
Os números vieram de um contador incrementado dentro do componente, e se repetiram idênticos em três execuções seguidas. A lição prática não é decorar 52 ou 53: é que o React reage em dezenas de renders, não em milhares. Se o seu contador passou de trinta, o loop já está formado.
Achando o culpado antes de a aba congelar
Quando o loop está em useEffect, o navegador trava antes de você conseguir
abrir o DevTools. A saída é instalar um limite seu, que estoura antes do React
e aponta o componente pelo nome.
function useLimiteDeRenders(nome, limite = 30) {
const contador = useRef(0);
contador.current += 1;
if (contador.current > limite) {
throw new Error(`${nome} renderizou ${contador.current} vezes seguidas — provável loop`);
}
}Estourou em 32 — o React ainda repete a passagem que falhou, por isso passa um
pouco do limite pedido. O importante é que o erro é seu, com o nome do
componente, e chega em milissegundos em vez de travar a aba. O useRef guarda o
contador sem provocar render nenhum, que é justamente o que se precisa aqui;
o mecanismo está em useRef.
Achado o componente, falta descobrir qual dependência mudou. Esse hook compara o array de dependências com o da passagem anterior e imprime a diferença:
function useEfeitoDepurado(nome, efeito, deps) {
const anteriores = useRef(deps);
deps.forEach((dep, i) => {
if (!Object.is(dep, anteriores.current[i])) {
console.log(`[${nome}] dep ${i} mudou:`, anteriores.current[i], '->', dep);
}
});
anteriores.current = deps;
useEffect(efeito, deps);
}Essa linha é o diagnóstico inteiro numa frase: a dependência 0 “mudou” de
{ status: 'confirmada' } para { status: 'confirmada' }. Ler isso no console
resolve em segundos um bug que costuma consumir uma tarde.
A correção de cada causa
| causa | mensagem que ela produz | correção |
|---|---|---|
setState no corpo |
Too many re-renders |
calcular no render, sem estado |
useEffect sem array |
aviso de Maximum update depth |
declarar as dependências, quase sempre [] |
| objeto novo nas dependências | aviso de Maximum update depth |
depender do campo primitivo |
onClick={handler()} |
Too many re-renders |
passar a função: onClick={handler} |
As quatro correções, aplicadas ao mesmo painel da clínica:
// causa 1: o que dá para calcular não vira estado
function Painel({ consultas }) {
const confirmadas = consultas.filter((c) => c.status === 'confirmada').length;
return <p>{confirmadas} de {consultas.length} confirmadas</p>;
}
// causas 2 e 3: array de dependências com valor primitivo
function Lista({ status }) {
const [consultas, setConsultas] = useState([]);
useEffect(() => {
setConsultas(banco.filter((c) => c.status === status));
}, [status]);
return <ul>{consultas.map((c) => <li key={c.id}>{c.paciente}</li>)}</ul>;
}
// causa 4: onClick recebe a função, não a chamada
function Confirmacao() {
const [status, setStatus] = useState('pendente');
return <button onClick={() => setStatus('confirmada')}>{status}</button>;
}Painel renderizou uma vez. Lista renderizou duas — a montagem e a chegada dos
dados — e parou. O clique fez Confirmacao renderizar mais uma, e só. É essa a
assinatura de um componente saudável: número pequeno, e um número que para de
crescer.
O próximo passo
Se o rastro aponta para um componente que você não reconhece, o problema provavelmente está num hook compartilhado ou num contexto. E se o console mostrar outra mensagem além dessas, vale conferir Rendered more hooks than during the previous render, que é o outro erro clássico de hook fora do lugar. Para revisar a ordem de estudo dos hooks antes de voltar ao código, o guia de React mostra onde estado, efeito e memorização entram na sequência.
Prefere aprender em vídeo?
Tem uma aula sobre este assunto no nosso canal.
Perguntas frequentes
Por que às vezes a aba trava e nem aparece erro vermelho?
Colocar array de dependências vazio resolve sempre?
useMemo resolve o problema da dependência que muda toda hora?
Qual a diferença entre Too many re-renders e Maximum update depth exceeded?
O erro pode vir de um componente que não é o que aparece no rastro?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com jsdom 30.0.1 e React 19.2.8, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- React — You Might Not Need an Effect — react.dev
- React — Removing Effect Dependencies — react.dev
- React — Keeping Components Pure — react.dev



