12k
All articles

Cómo solucionar el error 'Maximum Update Depth Exceeded' en React

Corrige el error maximum update depth exceeded en React con cambios de una línea para bucles de render, deps de efectos, handlers y throttling.

OpenReplay Team
OpenReplay Team
Cómo solucionar el error 'Maximum Update Depth Exceeded' en React

“Maximum update depth exceeded” significa que tu componente está atrapado en un bucle de renderizado infinito: una actualización de estado dispara un re-renderizado que vuelve a disparar la misma actualización de estado, y React aborta el proceso tras superar las 50 actualizaciones anidadas (su NESTED_UPDATE_LIMIT) para evitar que el navegador se congele.

Si alguna vez has visto cómo se bloquea la pestaña mientras la misma línea roja se acumula en la consola, sabes lo poco útil que resulta ese muro de texto al principio. La buena noticia es que la solución casi siempre consiste en cambiar una sola línea, una vez que identificas con qué patrón tropezaste.

Esta guía cubre el conjunto completo de causas, incluido el caso de los manejadores de alta frecuencia, con ejemplos de antes/después listos para copiar y pegar, una tabla de consulta de síntomas para orientarte rápido, y las herramientas para localizar el setter culpable en menos de un minuto. La causa raíz es idéntica en componentes de función y de clase: un bucle de actualización que nunca se estabiliza.

Puntos clave

  • El error es un bucle de renderizado infinito; React lo detiene cuando el recuento de actualizaciones anidadas supera 50 (NESTED_UPDATE_LIMIT en el código fuente del reconciliador).
  • onClick={handleClick()} llama a la función durante el renderizado y programa una actualización de estado en cada render. Pasa onClick={handleClick}, y onClick={() => handleClick(id)} cuando necesites argumentos.
  • La solución más reutilizable es la actualización funcional (setCount(prev => prev + 1)), que te permite eliminar ese estado del array de dependencias del efecto y rompe el bucle de lectura-y-escritura.
  • useCallback estabiliza la identidad de una función, no la frecuencia con la que se ejecuta, así que no resolverá bucles causados por manejadores de alta frecuencia como onScroll o el onDragMove de dnd-kit; en su lugar, aplica throttle o debounce a la actualización de estado.
  • El error #185 de los componentes de clase se lanza tanto en desarrollo como en producción, pero la variante de useEffect es solo una advertencia en desarrollo. En producción, ese bucle se ejecuta sin lanzar ningún error ni dejar señal alguna en la consola.

Tabla de consulta: síntoma → causa → solución

Localiza el síntoma que coincida con lo que estás viendo y ve directamente a la solución correspondiente.

SíntomaCausaSolución
Bucle sin clic alguno, comienza al montaronClick={fn()} llama al setter durante el renderizadoPasa onClick={fn}
setState en el nivel superior del componenteActualización de estado en la ruta de renderizadoMuévela a un manejador o a un efecto
El efecto se dispara en cada renderDependencias ausentes/incorrectas, u objeto/array en línea en las dependenciasCorrige las dependencias + useMemo/useCallback
El efecto lee y escribe el mismo estadoEl estado está en sus propias dependenciasActualizador funcional; elimina la dependencia
El bucle solo ocurre al arrastrar/hacer scroll/redimensionarsetState en cada evento de alta frecuenciaAplica throttle/debounce a la actualización
Padre e hijo se sobrescriben mutuamenteSincronización de estado bidireccionalHaz que el flujo sea unidireccional

Soluciones 1 y 2: referencias de manejadores y setState en la ruta de renderizado

Las dos victorias más rápidas están en cómo conectas los manejadores y dónde llamas al setter. onClick={handleClick()} llama a la función durante el renderizado y programa una actualización de estado en cada render; casi siempre lo que quieres es onClick={handleClick}, y onClick={() => handleClick(id)} cuando necesitas pasar argumentos.

// 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>

El mismo bucle ocurre cuando llamas a un setter directamente en el cuerpo del componente. Las actualizaciones de estado pertenecen a un manejador de eventos o a un efecto, nunca a la ruta de renderizado.

// 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);

Soluciones 3, 4 y 5: bucles por dependencias de efectos

La mayoría de los bucles en efectos provienen de dependencias que cambian de identidad o de un efecto que escribe el estado que lee. Un objeto o array literal escrito en línea dentro de un array de dependencias obtiene una nueva identidad en cada render, por lo que el efecto se vuelve a ejecutar en cada render; envuélvelo en useMemo (objetos/arrays) o useCallback (funciones) para que su referencia se mantenga estable.

// 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' }), []);

La solución más reutilizable es la actualización funcional. Cuando un efecto lee y escribe el mismo estado, setCount(prev => prev + 1) lee el valor previo desde el argumento del actualizador en lugar de hacerlo desde el closure, lo que te permite eliminar ese estado del array de dependencias y rompe el bucle.

// 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); }, []);

Para una función de la que dependa un efecto, o bien la envuelves en useCallback con las dependencias correctas, o bien la mueves dentro del efecto: una función declarada dentro del efecto se crea una vez por ejecución y no necesita ser una dependencia.

Solución 6: aplica throttle a los manejadores de alta frecuencia

useCallback estabiliza la identidad de una función entre renders, pero no cambia con qué frecuencia se ejecuta, por lo que no resolverá un bucle infinito causado por un manejador de alta frecuencia como onScroll, onMouseMove, onResize o el onDragMove de dnd-kit. Esos eventos se disparan decenas de veces por segundo, y cada setState programa otro render. En su lugar, aplica throttle o debounce a la actualización de estado.

// 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);

El throttle de lodash limita cuántas veces puede dispararse la función envuelta dentro de una ventana de tiempo determinada; el requestAnimationFrame nativo o un debounce también funcionan. La clave es el control de frecuencia, no la estabilidad de la referencia.

Solución 7: bucles de sincronización padre-hijo

La propagación bidireccional de estado (un efecto en el hijo que llama al setter del padre, lo que vuelve a renderizar al hijo, lo que dispara el efecto de nuevo) es otro bucle clásico. Eleva el estado a un único propietario y mantén el flujo de datos unidireccional, o transforma el valor en el padre en lugar de sincronizarlo de vuelta mediante un efecto.

Localízalo rápido (y el caso de los componentes de clase)

Trabaja de arriba abajo: lee el stack trace hasta el setter con nombre que encabeza el bucle, y luego abre el Profiler de React DevTools para encontrar el componente que se re-renderiza sin pausa. Añade why-did-you-render (probado con React 19, solo para desarrollo, y sin probar con React Compiler) para ver qué prop o estado cambió de identidad. Detéctalos en tiempo de linting: a partir de eslint-plugin-react-hooks 7.x, el plugin incluye reglas dedicadas set-state-in-render y set-state-in-effect que capturan los dos desencadenantes más comunes antes de ejecutar la aplicación. Ambas forman parte del preset recommended por defecto, así que basta con actualizar el plugin para activarlas; recommended-latest solo añade encima las reglas experimentales del compilador. set-state-in-render se dispara cuando un componente establece estado durante el renderizado sin nada que proteja esa llamada, que es precisamente la forma que degenera en un bucle.

El error tiene la misma causa raíz tanto en componentes de función como de clase; en los componentes de clase suele significar llamar a setState durante el renderizado o de forma incondicional dentro de componentDidUpdate. Vigila también el entorno: el error #185 de los componentes de clase se lanza tanto en desarrollo como en producción, pero la variante de useEffect es únicamente una advertencia de desarrollo. En el código fuente del reconciliador, la comprobación de actualizaciones pasivas anidadas está dentro de una guarda exclusiva de desarrollo y registra el mensaje en la consola en lugar de lanzar una excepción, de modo que una build de producción ejecuta ese bucle de efectos sin lanzar error, sin error boundary y sin señal en consola, y solo se manifiesta como una pestaña congelada.

Esa brecha en producción es donde el bucle se vuelve costoso de depurar. La consola muestra el error pero no qué interacción lo provocó, y en el caso de los bucles en efectos puede que no muestre error alguno. Las herramientas de session replay como OpenReplay capturan el error de consola, cuando existe, junto con la secuencia de acciones del usuario que lo precedieron, de modo que un simple stack trace (o un bloqueo silencioso) se convierte en un caso reproducible clic a clic que puedes reproducir contra las soluciones anteriores.

Una vez que emparejas tu síntoma con una fila de la tabla, el cambio suele ser de una sola línea: una referencia en lugar de una llamada, un useMemo alrededor de un objeto, o un actualizador funcional que te permite prescindir de una dependencia. Configura exhaustive-deps junto con las reglas más recientes set-state-in-* para que el próximo bucle haga fallar tu paso de linting en vez de los navegadores de tus usuarios.

Preguntas frecuentes

¿Cuál es la diferencia entre la variante de useEffect de este error y el error #185 de React?

Son dos cadenas distintas con comportamientos diferentes en tiempo de ejecución. El error #185 es la redacción correspondiente a los componentes de clase, que menciona setState dentro de componentWillUpdate o componentDidUpdate, y se lanza tanto en desarrollo como en producción. La variante de useEffect es una advertencia independiente y exclusiva de desarrollo procedente del código fuente del reconciliador, protegida por una guarda solo de desarrollo, de modo que en producción el bucle del efecto se ejecuta sin lanzar error, sin error boundary y sin ninguna señal en consola.

¿Por qué añadir useCallback no detiene mi bucle infinito al arrastrar o hacer scroll?

Porque useCallback estabiliza la identidad de una función entre renders, pero no cambia la frecuencia con la que esa función se ejecuta. Un manejador de alta frecuencia como onScroll, onMouseMove o el onDragMove de dnd-kit se dispara decenas de veces por segundo, y cada setState programa otro render, con independencia de si la referencia de la función está memoizada. La solución es el control de frecuencia: aplica throttle o debounce a la propia actualización de estado, usando throttle de lodash, un debounce o requestAnimationFrame.

¿La forma de actualizador funcional siempre me permite eliminar el estado del array de dependencias de un efecto?

Solo cuando la única necesidad que tiene el efecto de ese estado es calcular el siguiente valor. Escribir setCount(prev => prev + 1) lee el valor previo desde el argumento del actualizador en lugar de hacerlo desde el closure, así que count puede salir del array de dependencias y el bucle de lectura-y-escritura se rompe. Si el efecto también lee ese estado para otra lógica, como bifurcaciones o pasarlo a otra función, sigues necesitándolo como dependencia y debes romper el bucle de otra manera.

¿Por qué el error aparece en desarrollo pero mi build de producción simplemente se congela en silencio?

Porque la guarda de actualizaciones pasivas anidadas de la variante de useEffect está envuelta en una comprobación exclusiva de desarrollo dentro del reconciliador de React, así que solo emite una advertencia en consola durante el desarrollo. En una build de producción esa guarda no se ejecuta, lo que significa que el mismo bucle de efectos corre sin lanzar error y sin mensaje en consola, manifestándose únicamente como una pestaña congelada o renders descontrolados. El error #185 de los componentes de clase es distinto y se lanza en ambos entornos.

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.