useEffect borra el estado: dependencia con referencia nueva

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 data no 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 y refetchOnWindowFocus vale true, 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 mientras data es undefined. Si un efecto depende de él y hace setFilas(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

  1. Identifica la dependencia que cambia. La documentación de useEffect sugiere registrar las dependencias y compararlas con Object.is entre 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 false sin que el contenido haya cambiado, esa es la dependencia.

  2. Inicializa solo en la transición de cerrado a abierto. Guarda el valor anterior de open en un useRef:

    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.

  3. 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 con key={data.id}. React reinicia el estado solo cuando cambia la clave. Una dependencia primitiva como dataUpdatedAt evita 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.

  4. 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 useMemo como último recurso para estabilizar una dependencia; primero conviene mover el valor fuera del componente o leer solo valores primitivos. Si pasas una función select a useQuery, la documentación de TanStack recomienda memoizarla.

  5. Reduce las recargas donde el usuario edita. La documentación de TanStack indica que staleTime es la forma recomendada de evitar recargas excesivas:

    useQuery({
      queryKey: ['factura', id],
      queryFn: () => obtenerFactura(id),
      refetchOnWindowFocus: false,
      staleTime: 60_000,
    });
  6. Escribe pruebas que fallen con el código anterior. La primera reproduce la secuencia del diálogo; la segunda usa Profiler, cuyo onRender se 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 pasa enviar) y los imports habituales de Vitest, Testing Library y TanStack Query. El throw es lo que evita el cuelgue: sin él, render nunca devuelve el control cuando hay un bucle. Según su documentación, Profiler está 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 key para 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.

Referencias

Comentarios