Un componente de React pierde lo que el usuario acaba de escribir sin que nadie haya tocado ese estado. Se ha visto de tres formas:
- Un diálogo para enviar un informe por correo pierde los destinatarios escritos y el mensaje de error después de un envío fallido.
- Un formulario pierde los importes editados cuando termina una recarga de datos en segundo plano.
- Una prueba nueva con Vitest y Testing Library se cuelga o falla.
En el tercer caso, la consola muestra este aviso:
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
La causa común es un useEffect que inicializa o reinicia estado local y depende de un valor cuya referencia cambia en cada render. Este artículo explica por qué se dispara y cómo corregirlo.
Por qué pasa
La documentación de React indica que compara cada dependencia con su valor anterior mediante Object.is. Un array u objeto creado durante el render es un valor distinto en cada render, aunque su contenido sea idéntico, así que el efecto se vuelve a ejecutar. Si ese efecto llama a setState con valores iniciales, pisa lo que escribió el usuario.
Los tres casos son variantes de esto:
- Diálogo: el padre calcula
const iniciales = guardados.length ? guardados : ['']. Cuando no hay destinatarios guardados,['']es un array nuevo en cada render del padre. Al fallar el envío, el padre cambia su estado y se renderiza, el efecto detecta una dependencia "nueva" y restablece destinatarios y error. - Formulario con datos remotos: TanStack Query comparte estructuralmente los resultados, y su documentación indica que la referencia de
datano cambia si los datos no cambiaron. Pero si la recarga trae algo distinto (aunque sea un campo que no editas), la referencia cambia y el efecto reinicializa el formulario. Las consultas están obsoletas por defecto yrefetchOnWindowFocusvaletrue, así que esa recarga ocurre sola al volver a la ventana. - Bucle de renders: con
const { data = [] } = useQuery(...), el valor por defecto crea un array nuevo en cada render mientrasdataesundefined. Si un efecto depende de él y hacesetFilas(data.map(...)), cada render provoca otro. React solo registra el aviso de arriba en la consola y el bucle continúa: una prueba que espera ese render no termina.
Además, según la guía "You Might Not Need an Effect", los efectos son para sincronizar con sistemas externos; reiniciar estado por un cambio de props normalmente no necesita un efecto.
Solución
-
Identifica la dependencia que cambia. La documentación de
useEffectsugiere registrar las dependencias y compararlas conObject.isentre renders:const anterior = useRef(destinatariosIniciales); console.log('misma referencia:', Object.is(anterior.current, destinatariosIniciales)); anterior.current = destinatariosIniciales;Es solo para depurar: bórralo después. Si imprime
falsesin que el contenido haya cambiado, esa es la dependencia. -
Inicializa solo en la transición de cerrado a abierto. Guarda el valor anterior de
openen unuseRef:function InformeDialog({ open, destinatariosIniciales, onEnviar }) { const [destinatarios, setDestinatarios] = useState([]); const [error, setError] = useState(null); const estabaAbierto = useRef(false); useEffect(() => { if (open && !estabaAbierto.current) { setDestinatarios(destinatariosIniciales); setError(null); } estabaAbierto.current = open; }, [open, destinatariosIniciales]); // ... }La lista de dependencias sigue completa (el linter no se queja), pero la condición impide reinicializar cuando solo cambia la referencia del array.
-
Reinicializa solo cuando cambia la clave de negocio. Para formularios con datos remotos, guarda para qué registro se inicializó el estado:
function FormularioFactura({ id }) { const { data } = useQuery({ queryKey: ['factura', id], queryFn: () => obtenerFactura(id) }); const [monto, setMonto] = useState(''); const inicializadoPara = useRef(null); useEffect(() => { if (data && inicializadoPara.current !== data.id) { inicializadoPara.current = data.id; setMonto(String(data.monto)); } }, [data]); // ... }La alternativa que recomienda React es no usar un efecto: separa los campos en un componente hijo con
useState(() => String(factura.monto))y renderízalo conkey={data.id}. React reinicia el estado solo cuando cambia la clave. Una dependencia primitiva comodataUpdatedAtevita el problema de la referencia, pero cambia en cada consulta exitosa, así que sirve para vistas de solo lectura y no para pantallas donde se edita. -
Usa referencias estables. Saca los valores por defecto del componente o memoízalos:
const SIN_FILAS = []; function ListaFilas({ obtenerFilas }) { const { data = SIN_FILAS } = useQuery({ queryKey: ['filas'], queryFn: obtenerFilas }); // ... } const destinatariosIniciales = useMemo( () => (guardados.length ? guardados : ['']), [guardados], );React presenta
useMemocomo último recurso para estabilizar una dependencia; primero conviene mover el valor fuera del componente o leer solo valores primitivos. Si pasas una funciónselectauseQuery, la documentación de TanStack recomienda memoizarla. -
Reduce las recargas donde el usuario edita. La documentación de TanStack indica que
staleTimees la forma recomendada de evitar recargas excesivas:useQuery({ queryKey: ['factura', id], queryFn: () => obtenerFactura(id), refetchOnWindowFocus: false, staleTime: 60_000, }); -
Escribe pruebas que fallen con el código anterior. La primera reproduce la secuencia del diálogo; la segunda usa
Profiler, cuyoonRenderse llama en cada commit, para cortar un bucle:it('conserva destinatarios y error tras un envío fallido', async () => { const user = userEvent.setup(); const enviar = vi.fn().mockRejectedValue(new Error('fallo')); render(<PaginaInforme enviar={enviar} />); await user.click(screen.getByText('Abrir')); const campo = screen.getByLabelText('Destinatarios'); await user.clear(campo); await user.type(campo, 'ana, luis'); await user.click(screen.getByText('Enviar')); expect(await screen.findByRole('alert')).toBeTruthy(); expect(screen.getByLabelText('Destinatarios').value).toBe('ana, luis'); }); it('no entra en un bucle de renders', () => { let commits = 0; const onRender = () => { commits += 1; if (commits > 10) throw new Error('Demasiados renders: posible bucle de efectos'); }; render( <QueryClientProvider client={new QueryClient()}> <Profiler id="lista" onRender={onRender}> <ListaFilas obtenerFilas={() => new Promise(() => {})} /> </Profiler> </QueryClientProvider>, ); expect(commits).toBeLessThan(10); });Las pruebas asumen un componente
PaginaInforme(el padre que renderiza el diálogo y le pasaenviar) y los imports habituales de Vitest, Testing Library y TanStack Query. Elthrowes lo que evita el cuelgue: sin él,rendernunca devuelve el control cuando hay un bucle. Según su documentación,Profilerestá desactivado por defecto en las compilaciones de producción, así que este guardia es solo para pruebas.
Cómo verificar que quedó resuelto
- Ejecuta las pruebas con el código anterior: deben fallar (el campo queda vacío, el aviso desaparece o se lanza el error de "Demasiados renders"). Con la corrección deben pasar.
- Reproduce en el navegador la secuencia exacta: abre el diálogo, escribe destinatarios, fuerza un fallo del envío (por ejemplo, con la red desconectada en las herramientas de desarrollo) y comprueba que los destinatarios y el mensaje siguen ahí.
- En el formulario, edita un importe, cambia de pestaña y vuelve a la ventana: el valor editado debe conservarse.
- Ejecuta la suite completa: ningún archivo de pruebas debería tardar de forma anómala. Un archivo que antes se colgaba debe terminar en pocos segundos.
Variantes y errores relacionados
Efecto sin lista de dependencias
Un useEffect sin segundo argumento se ejecuta tras cada render. La documentación pide revisar primero que no falte la lista de dependencias.
Funciones como dependencia
Una función definida dentro del componente también es nueva en cada render y provoca lo mismo. La guía Removing Effect Dependencies propone moverla fuera del componente o dentro del efecto; useCallback queda como último recurso.
Ajustar estado durante el render
React también documenta guardar la información del render anterior en el estado y ajustarlo durante el render. Es más eficiente que un efecto, pero la propia guía advierte que la mayoría de los componentes no lo necesitan: prefiere key o calcular el valor al renderizar.
StrictMode
El patrón del paso 2 funciona igual dentro de <StrictMode>, que en desarrollo ejecuta un ciclo adicional de configuración y limpieza de cada efecto al montar: la condición sobre estabaAbierto evita inicializar dos veces.
Resumen
- React compara las dependencias con
Object.is: un array u objeto creado en el render es "nuevo" cada vez y vuelve a disparar el efecto. - Un efecto que inicializa estado con ese tipo de dependencia borra lo que el usuario escribió; si además cambia el estado con un valor nuevo, genera un bucle.
- Inicializa solo en la transición cerrado a abierto o cuando cambia la clave de negocio, o usa
keypara reiniciar el componente. - Estabiliza los valores por defecto (constantes fuera del componente o
useMemo) y limita las recargas en pantallas de edición. - Cubre el caso con pruebas que fallen antes y pasen después, incluyendo un guardia de menos de 10 renders.
Comentarios
Publicar un comentario