En 04-03 quedó pendiente una promesa: el modelo mental correcto para lo que las clases llamaban «ciclo de vida» no es una secuencia de momentos (componentDidMount, componentDidUpdate, componentWillUnmount), sino la sincronización con un sistema externo. Esta lección cumple esa promesa. useEffect es la herramienta que conecta un componente de React con todo lo que vive fuera de React: temporizadores, eventos del navegador, el título de la pestaña, una conexión de red, un servidor. Pero antes de aprender a usarlo hay que aprender a no usarlo, porque useEffect es el hook peor empleado del ecosistema: se mete en él lógica que pertenece al render o a un manejador de eventos, y el resultado son renders de más, parpadeos, bucles infinitos y errores imposibles de reproducir. Aquí verás su anatomía completa, cuándo se ejecuta de verdad, por qué StrictMode lo llama dos veces y por qué eso es bueno, cuatro casos legítimos en CicloUrbano —incluida la carga de datos con condiciones de carrera— y los antipatrones que debes reconocer a la primera.

Contenido

  1. Qué es un efecto y qué no lo es
  2. La mayor parte del código no necesita useEffect
  3. Anatomía: función de efecto, limpieza y dependencias
  4. Las tres formas del array de dependencias
  5. El ciclo real de ejecución
  6. StrictMode, la doble ejecución y la prueba de la limpieza
  7. Caso 1: un temporizador de disponibilidad
  8. Caso 2: escuchar un evento del navegador
  9. Caso 3: cargar datos de una API y la condición de carrera
  10. Caso 4: sincronizar el título del documento
  11. El array de dependencias a fondo
  12. Bucles infinitos: causas reales y soluciones de verdad
  13. Antipatrones que hay que reconocer
  14. useLayoutEffect y la carga de datos moderna

  1. Qué es un efecto y qué no lo es

Empecemos por la definición precisa, porque la palabra «efecto» se usa mal constantemente.

Un efecto es un trozo de código que sincroniza el componente con un sistema externo a React, y que React ejecuta después de pintar en pantalla, no durante el render.

Las dos partes importan. «Sistema externo» significa cualquier cosa que React no controla:

Sistema externo Ejemplo en CicloUrbano
Temporizadores del navegador setInterval que refresca las plazas libres de una estación
API del DOM fuera del árbol de React document.title, localStorage, matchMedia
Eventos globales window.addEventListener('online', …)
Red fetch al endpoint de bicicletas
Bibliotecas de terceros Un mapa de OpenStreetMap con las estaciones
Conexiones persistentes Un WebSocket que avisa de bicicletas devueltas

Y ahora lo que no es un efecto, que es donde está la mayoría de los errores:

Situación ¿Es un efecto? Dónde va de verdad
Filtrar la flota por tipo para mostrarla ❌ No Durante el render, como valor derivado
Calcular el precio total de la reserva ❌ No Durante el render, como valor derivado
Enviar la reserva al pulsar «Confirmar» ❌ No En el manejador onSubmit
Registrar la analítica de un clic ❌ No En el manejador onClick
Mostrar un aviso al pulsar un botón ❌ No En el manejador
Suscribirse a un temporizador mientras el panel está visible ✅ Sí En useEffect
Cargar las estaciones al mostrar la pantalla ✅ Sí En useEffect (o en un enrutador/biblioteca)

La pregunta que separa un caso del otro es sencilla: ¿esto ocurre porque el usuario ha hecho algo concreto, o porque el componente está en pantalla? Si es lo primero, va en un manejador. Si es lo segundo, es un efecto.

  1. La mayor parte del código no necesita useEffect

Merece un apartado propio porque es el consejo más rentable de toda la lección. Estos son los tres casos en los que la gente recurre a useEffect sin necesitarlo.

Transformar datos para el render

// ❌ MAL: un estado y un efecto para algo que se calcula
function Catalogo({ bicicletas, tipoElegido }) {
  const [visibles, setVisibles] = useState([]);

  useEffect(() => {
    setVisibles(
      tipoElegido === 'todos'
        ? bicicletas
        : bicicletas.filter((bicicleta) => bicicleta.tipo === tipoElegido)
    );
  }, [bicicletas, tipoElegido]);
  ...
}

Este código funciona, pero provoca dos renders por cada cambio: uno con la lista vieja y otro, después del efecto, con la nueva. Durante una fracción de segundo la pantalla muestra datos incorrectos. Y si olvidas una dependencia, muestra datos incorrectos para siempre.

// ✅ BIEN: es un valor derivado, como en 04-01
function Catalogo({ bicicletas, tipoElegido }) {
  const visibles =
    tipoElegido === 'todos'
      ? bicicletas
      : bicicletas.filter((bicicleta) => bicicleta.tipo === tipoElegido);
  ...
}

Un render, siempre coherente, imposible de desincronizar. Si el cálculo fuera realmente caro, la solución no es un efecto sino useMemo (08-03).

Responder a un evento del usuario

// ❌ MAL: el efecto no sabe POR QUÉ cambió el estado
useEffect(() => {
  if (reservaConfirmada) {
    enviarAnalitica('reserva_confirmada');
  }
}, [reservaConfirmada]);

// ✅ BIEN: la analítica pertenece al manejador que provocó el hecho
function manejarConfirmacion({ bicicleta, horas, total }) {
  enviarAnalitica('reserva_confirmada', { bicicletaId: bicicleta.id, horas, total });
  setBicicletaSeleccionada(null);
}

La versión con efecto tiene un fallo grave: si el estado reservaConfirmada vuelve a ponerse a true por otro motivo —al recargar un borrador guardado, por ejemplo—, la analítica se dispara de nuevo. El manejador solo se ejecuta cuando alguien pulsa el botón, que es justo lo que queremos medir.

Reiniciar el estado al cambiar una prop

// ❌ MAL: render con datos viejos y luego corrección
useEffect(() => {
  setHoras(1);
  setErrores({});
}, [bicicleta.id]);

// ✅ BIEN: cambia la identidad del componente con key (05-01, apartado 10)
<PanelReserva key={bicicleta.id} bicicleta={bicicleta} />

Regla que resume el apartado: si puedes calcularlo durante el render o hacerlo en un manejador, no es un efecto.

  1. Anatomía: función de efecto, limpieza y dependencias

useEffect(() => {
  // 1. FUNCIÓN DE EFECTO: se ejecuta después del pintado
  const conexion = crearConexion(estacionId);
  conexion.abrir();

  // 2. FUNCIÓN DE LIMPIEZA (opcional): deshace lo que hizo el efecto
  return () => {
    conexion.cerrar();
  };
}, [estacionId]);   // 3. ARRAY DE DEPENDENCIAS

Las tres piezas, una por una:

  • La función de efecto contiene lo que hay que hacer para empezar a sincronizar. React la ejecuta después de aplicar los cambios al DOM y de que el navegador haya pintado, así que nunca bloquea la interfaz.
  • La función de limpieza es lo que devuelve la función de efecto. Contiene lo necesario para dejar de sincronizar. React la ejecuta antes de volver a ejecutar el efecto y también al desmontar el componente. Si tu efecto crea, abre, suscribe o arranca algo, la limpieza debe destruirlo, cerrarlo, desuscribirlo o pararlo. Es una simetría casi mecánica:
Lo que hace el efecto Lo que debe hacer la limpieza
setInterval / setTimeout clearInterval / clearTimeout
addEventListener removeEventListener
conexion.abrir() conexion.cerrar()
fetch(...) controlador.abort()
biblioteca.iniciar(nodo) biblioteca.destruir()
Cambiar document.title Restaurar el título anterior, si procede
  • El array de dependencias le dice a React de qué valores depende la sincronización. Si ninguno ha cambiado respecto al render anterior, React se salta el efecto por completo.

  1. Las tres formas del array de dependencias

Forma Cuándo se ejecuta el efecto Cuándo se ejecuta la limpieza Uso típico
useEffect(fn)sin array Después de cada render Antes de cada nueva ejecución y al desmontar Casi nunca. Suele ser un olvido
useEffect(fn, [])array vacío Solo después del primer render Solo al desmontar Suscripciones globales que no dependen de nada
useEffect(fn, [a, b])con dependencias Tras el primer render y cada vez que a o b cambien Antes de cada reejecución y al desmontar El caso normal

Ejemplos de las tres, con el mismo componente:

// Sin array: en cada render se crea un temporizador nuevo. Casi seguro un bug.
useEffect(() => {
  console.log('Se ejecuta en TODOS los renders');
});

// Array vacío: una suscripción global durante toda la vida del componente
useEffect(() => {
  function manejarConexion() { setEnLinea(navigator.onLine); }
  window.addEventListener('online', manejarConexion);
  window.addEventListener('offline', manejarConexion);
  return () => {
    window.removeEventListener('online', manejarConexion);
    window.removeEventListener('offline', manejarConexion);
  };
}, []);

// Con dependencias: la sincronización se rehace cuando cambia la estación
useEffect(() => {
  const conexion = crearConexionDisponibilidad(estacionId);
  conexion.abrir();
  return () => conexion.cerrar();
}, [estacionId]);

La comparación de dependencias es con Object.is, es decir, por referencia para objetos, arrays y funciones. Esto es la causa del 90 % de los bucles infinitos y lo tratamos en el apartado 12.

  1. El ciclo real de ejecución

Este diagrama sustituye para siempre a la tabla de métodos del ciclo de vida:

flowchart TD
    A["Render: React llama al componente"] --> B["Commit: React aplica los cambios al DOM"]
    B --> C["El navegador pinta la pantalla"]
    C --> D["React ejecuta el EFECTO"]
    D --> E{"¿Nuevo render?"}
    E -- "Las dependencias NO cambian" --> F["No pasa nada:<br/>el efecto se salta"]
    F --> E
    E -- "Las dependencias SÍ cambian" --> G["Ejecuta la LIMPIEZA del efecto anterior"]
    G --> H["Ejecuta el EFECTO con los valores nuevos"]
    H --> E
    E -- "El componente se desmonta" --> I["Ejecuta la LIMPIEZA por última vez"]
    style D fill:#dcfce7
    style G fill:#fde68a
    style I fill:#fecaca

Lo que hay que retener: limpieza y efecto van siempre emparejados. Nunca se ejecuta un efecto nuevo sin haber limpiado el anterior. Por eso el modelo mental de «montar / actualizar / desmontar» es engañoso: desde el punto de vista de useEffect no hay tres momentos distintos, solo hay empezar a sincronizar y dejar de sincronizar, repetidos tantas veces como haga falta.

Traducido a CicloUrbano: si el panel está conectado a la estación est-01 y el usuario cambia a est-02, React cierra la conexión con est-01 y abre la de est-02. Ese «cambio» no es un caso especial: es una parada seguida de un arranque.

  1. StrictMode, la doble ejecución y la prueba de la limpieza

En 01-03 viste que <StrictMode> monta cada componente dos veces en desarrollo. Con los efectos el comportamiento es aún más llamativo: React ejecuta el efecto, ejecuta su limpieza y vuelve a ejecutar el efecto, todo en el montaje inicial.

Consola en desarrollo con StrictMode:
  Conexión abierta con est-01
  Conexión cerrada con est-01
  Conexión abierta con est-01

Mucha gente reacciona quitando StrictMode. Es exactamente la reacción equivocada. Lo que React está haciendo es someter tu efecto a una prueba: si el componente se desmonta y se vuelve a montar (algo que pasa constantemente en aplicaciones reales al navegar entre rutas, en 06-01), ¿queda todo en su sitio?

  • Si tu efecto está bien escrito, la secuencia «efecto → limpieza → efecto» deja el sistema en el mismo estado que una sola ejecución. No notas nada.
  • Si le falta la limpieza, la doble ejecución lo hace visible: dos temporizadores corriendo a la vez, dos suscripciones, dos peticiones. El fallo estaba ahí antes; StrictMode solo lo saca a la luz en desarrollo en lugar de en producción.
// ❌ Sin limpieza: con StrictMode verás DOS temporizadores incrementando a la vez
useEffect(() => {
  setInterval(() => setSegundos((previos) => previos + 1), 1000);
}, []);

// ✅ Con limpieza: la doble ejecución es indistinguible de una sola
useEffect(() => {
  const id = setInterval(() => setSegundos((previos) => previos + 1), 1000);
  return () => clearInterval(id);
}, []);

En producción StrictMode no duplica nada. La regla de oro: si quitar StrictMode arregla tu problema, el problema sigue ahí.

  1. Caso 1: un temporizador de disponibilidad

Primer caso legítimo. DisponibilidadEstacion consulta cada diez segundos cuántas plazas libres tiene la estación y lo muestra en pantalla.

// src/componentes/DisponibilidadEstacion.jsx
import { useState, useEffect } from 'react';
import estilos from './DisponibilidadEstacion.module.css';

/**
 * Plazas libres de una estación, refrescadas periódicamente.
 * Props:
 *  - estacion       (objeto Estacion, obligatorio)
 *  - intervaloMs    (número, opcional, por defecto 10000)
 */
function DisponibilidadEstacion({ estacion, intervaloMs = 10000 }) {
  const [plazasLibres, setPlazasLibres] = useState(estacion.plazas);
  const [ultimaLectura, setUltimaLectura] = useState(null);

  useEffect(() => {
    // EMPEZAR a sincronizar con el temporizador del navegador
    const identificador = setInterval(() => {
      const libres = consultarPlazasLibres(estacion.id);
      setPlazasLibres(libres);
      setUltimaLectura(new Date());
    }, intervaloMs);

    // DEJAR de sincronizar
    return () => clearInterval(identificador);
  }, [estacion.id, intervaloMs]);

  return (
    <p className={estilos.disponibilidad} aria-live="polite">
      {estacion.nombre}: {plazasLibres} de {estacion.plazas} plazas libres
      {ultimaLectura && (
        <span className={estilos.marca}> · {ultimaLectura.toLocaleTimeString('es-ES')}</span>
      )}
    </p>
  );
}

export default DisponibilidadEstacion;

Detalles que hacen que este efecto sea correcto:

  • La dependencia es estacion.id, no estacion. El objeto estacion podría ser una referencia nueva en cada render del padre aunque los datos sean idénticos, y eso reiniciaría el temporizador constantemente. El identificador es una cadena: se compara por valor.
  • intervaloMs está en las dependencias porque el temporizador depende de él. Si el padre cambia la frecuencia, hay que rehacer la sincronización.
  • setPlazasLibres no está en las dependencias: los actualizadores son estables (05-01, apartado 2).
  • La limpieza cancela el temporizador. Sin ella, cambiar de estación dejaría el temporizador anterior corriendo para siempre, escribiendo en un componente que ya muestra otra estación.

  1. Caso 2: escuchar un evento del navegador

CicloUrbano debe avisar cuando el dispositivo pierde la conexión, porque sin red no se pueden confirmar reservas.

// src/componentes/AvisoConexion.jsx
import { useState, useEffect } from 'react';
import Aviso from './Aviso.jsx';

function AvisoConexion() {
  const [enLinea, setEnLinea] = useState(() => navigator.onLine);

  useEffect(() => {
    function manejarEnLinea() { setEnLinea(true); }
    function manejarSinLinea() { setEnLinea(false); }

    window.addEventListener('online', manejarEnLinea);
    window.addEventListener('offline', manejarSinLinea);

    return () => {
      window.removeEventListener('online', manejarEnLinea);
      window.removeEventListener('offline', manejarSinLinea);
    };
  }, []);

  if (enLinea) return null;

  return (
    <Aviso tono="advertencia">
      Sin conexión. Puedes consultar el catálogo, pero no confirmar reservas.
    </Aviso>
  );
}

export default AvisoConexion;

Tres observaciones:

  • Las funciones manejadoras se declaran dentro del efecto. Si estuvieran fuera, en el cuerpo del componente, serían referencias nuevas en cada render y el linter exigiría incluirlas en las dependencias, lo que reiniciaría la suscripción sin motivo.
  • removeEventListener necesita exactamente la misma referencia que se pasó a addEventListener. Escribir window.removeEventListener('online', () => setEnLinea(true)) no elimina nada, porque es una función distinta. Este error deja escuchadores acumulándose y es una fuga de memoria clásica.
  • El estado inicial usa inicialización perezosa (() => navigator.onLine) para no consultar la API del navegador en cada render.

  1. Caso 3: cargar datos de una API y la condición de carrera

El caso más frecuente y el que más problemas da. Empezamos por la versión ingenua:

// ⚠️ Funciona… hasta que el usuario cambia rápido de estación
function PanelActividad({ estacionId }) {
  const [bicicletas, setBicicletas] = useState([]);

  useEffect(() => {
    fetch(`/api/estaciones/${estacionId}/bicicletas`)
      .then((respuesta) => respuesta.json())
      .then((datos) => setBicicletas(datos));
  }, [estacionId]);
  ...
}

El problema tiene nombre: condición de carrera (race condition). Si el usuario selecciona est-01 y enseguida est-02, se lanzan dos peticiones. Nada garantiza que lleguen en orden.

sequenceDiagram
    participant U as Usuario
    participant C as Componente
    participant S as Servidor
    U->>C: Selecciona est-01
    C->>S: GET /api/estaciones/est-01/bicicletas
    U->>C: Selecciona est-02 (rápido)
    C->>S: GET /api/estaciones/est-02/bicicletas
    S-->>C: Respuesta de est-02 (llega antes: es más ligera)
    Note over C: setBicicletas(bicis de est-02) ✅
    S-->>C: Respuesta de est-01 (llega después: iba lenta)
    Note over C: setBicicletas(bicis de est-01) ❌
    Note over U: La pantalla dice «est-02»<br/>pero muestra las bicis de est-01

Hay dos soluciones, y lo idiomático es aplicar las dos.

La bandera ignorar

Aprovecha la limpieza para marcar la respuesta anterior como obsoleta:

useEffect(() => {
  let ignorar = false;

  fetch(`/api/estaciones/${estacionId}/bicicletas`)
    .then((respuesta) => respuesta.json())
    .then((datos) => {
      if (!ignorar) {            // solo actualiza si este efecto sigue vigente
        setBicicletas(datos);
      }
    });

  return () => { ignorar = true; };   // al cambiar estacionId, invalida esta petición
}, [estacionId]);

La clave está en que cada ejecución del efecto tiene su propia variable ignorar, por el cierre de JavaScript. Cuando cambia estacionId, React ejecuta la limpieza del efecto viejo, que pone su ignorar a true. Si esa respuesta llega tarde, se descarta.

AbortController

La bandera evita el error, pero la petición sigue viajando y consumiendo red. AbortController la cancela de verdad:

useEffect(() => {
  const controlador = new AbortController();

  fetch(`/api/estaciones/${estacionId}/bicicletas`, { signal: controlador.signal })
    .then((respuesta) => respuesta.json())
    .then((datos) => setBicicletas(datos))
    .catch((error) => {
      if (error.name !== 'AbortError') {   // cancelar no es un fallo real
        setError(error.message);
      }
    });

  return () => controlador.abort();
}, [estacionId]);

La versión completa que usaremos en CicloUrbano

// src/componentes/PanelActividad.jsx
import { useState, useEffect } from 'react';
import ListaBicicletas from './ListaBicicletas.jsx';
import Aviso from './Aviso.jsx';

/**
 * Bicicletas de una estación, cargadas desde la API.
 * Props:
 *  - estacionId (cadena, obligatorio)
 */
function PanelActividad({ estacionId }) {
  const [bicicletas, setBicicletas] = useState([]);
  const [estadoCarga, setEstadoCarga] = useState('inactivo');
  const [error, setError] = useState(null);

  useEffect(() => {
    const controlador = new AbortController();
    let ignorar = false;

    async function cargar() {
      setEstadoCarga('cargando');
      setError(null);
      try {
        const respuesta = await fetch(
          `/api/estaciones/${estacionId}/bicicletas`,
          { signal: controlador.signal }
        );
        if (!respuesta.ok) {
          throw new Error(`El servidor respondió ${respuesta.status}`);
        }
        const datos = await respuesta.json();
        if (!ignorar) {
          setBicicletas(datos);
          setEstadoCarga('exito');
        }
      } catch (fallo) {
        if (fallo.name === 'AbortError') return;   // cancelación esperada
        if (!ignorar) {
          setError(fallo.message);
          setEstadoCarga('error');
        }
      }
    }

    cargar();

    return () => {
      ignorar = true;
      controlador.abort();
    };
  }, [estacionId]);

  if (estadoCarga === 'cargando') return <p aria-live="polite">Cargando bicicletas…</p>;
  if (estadoCarga === 'error') return <Aviso tono="error">No se pudo cargar la estación: {error}</Aviso>;

  return <ListaBicicletas bicicletas={bicicletas} />;
}

export default PanelActividad;

Dos detalles técnicos que suelen pasarse por alto:

  • La función de efecto no puede ser async. Una función async devuelve una promesa, y React espera que el valor devuelto sea la función de limpieza. Por eso se declara cargar() dentro y se la llama; nunca useEffect(async () => {…}, []).
  • fetch no lanza error con un 404 o un 500. Solo lanza si falla la red. Hay que comprobar respuesta.ok a mano, como hace el ejemplo.
  • La estructura de estado sigue el principio 3 de 05-01: estadoCarga es una cadena con valores excluyentes, no tres booleanos que puedan contradecirse.

  1. Caso 4: sincronizar el título del documento

El caso más pequeño y el más didáctico, porque enseña la palabra clave: sincronizar.

// src/App.jsx (fragmento)
const disponibles = bicicletas.filter((bicicleta) => bicicleta.estado === 'disponible').length;

useEffect(() => {
  document.title = `CicloUrbano · ${disponibles} bicis disponibles`;
}, [disponibles]);

No estamos «ejecutando código al montar»: estamos declarando que el título de la pestaña debe reflejar el número de bicicletas disponibles, siempre. React se encarga de rehacerlo cuando ese número cambie. Ese cambio de perspectiva —de «cuándo se ejecuta» a «qué debe mantenerse verdadero»— es el modelo mental que se prometió en 04-03.

Si quisieras ser estricto, la limpieza restauraría el título original al desmontar:

useEffect(() => {
  const tituloAnterior = document.title;
  document.title = `CicloUrbano · ${disponibles} bicis disponibles`;
  return () => { document.title = tituloAnterior; };
}, [disponibles]);

  1. El array de dependencias a fondo

Qué entra en el array

Todo valor reactivo que se use dentro del efecto. Un valor es reactivo si se declara dentro del componente y puede cambiar entre renders: props, estado, y cualquier variable o función derivada de ellos.

Valor usado en el efecto ¿Va en las dependencias? Motivo
Una prop (estacionId) ✅ Sí Puede cambiar en cada render
Un estado (horasReserva) ✅ Sí Puede cambiar
Una constante derivada (const url = ...id) ✅ Sí Depende de valores reactivos
El actualizador de useState ❌ No React garantiza que es estable
El despachar de useReducer ❌ No Estable (05-05)
Un ref (miRef) ❌ No El objeto es estable (05-03)
Una constante fuera del componente ❌ No No es reactiva: nunca cambia
Una importación (bicicletas de dominio.js) ❌ No Vive fuera del componente

Por qué mentirle rompe cosas

Es tentador quitar una dependencia para que el efecto «deje de dispararse». Nunca funciona: el efecto seguirá usando el valor del render en el que se ejecutó por última vez, es decir, un valor congelado (la instantánea de 05-01).

// ❌ Mentira: el efecto se suscribe a la primera estación y ya no cambia jamás
useEffect(() => {
  const conexion = crearConexionDisponibilidad(estacionId);
  conexion.abrir();
  return () => conexion.cerrar();
}, []);   // el linter avisa: falta 'estacionId'

El usuario cambia de estación, la interfaz muestra est-02 y los datos que llegan siguen siendo los de est-01. Es exactamente el tipo de error que nadie reproduce y que aparece en producción.

El linter

eslint-plugin-react-hooks (04-04) incluye la regla exhaustive-deps, que compara el contenido del efecto con el array y avisa de lo que falta:

React Hook useEffect has a missing dependency: 'estacionId'.
Either include it or remove the dependency array.  react-hooks/exhaustive-deps

Trátalo como un error, no como una sugerencia. Y nunca lo silencies con // eslint-disable-next-line react-hooks/exhaustive-deps: si el aviso te molesta, la solución no es callarlo sino cambiar el código para que la dependencia sobre.

  1. Bucles infinitos: causas reales y soluciones de verdad

Síntoma: la pestaña se congela, la consola escupe miles de líneas, o React lanza «Maximum update depth exceeded». Siempre es la misma estructura: el efecto cambia algo que está en sus propias dependencias.

Causa 1: un objeto o array recreado en cada render

// ❌ BUCLE: 'filtros' es un objeto NUEVO en cada render
function Catalogo({ tipo }) {
  const filtros = { tipo, estado: 'disponible' };

  useEffect(() => {
    buscarBicicletas(filtros).then(setResultados);
  }, [filtros]);   // Object.is(objetoNuevo, objetoViejo) === false, SIEMPRE
}

Cada render crea un objeto distinto; React ve una dependencia cambiada, ejecuta el efecto, el efecto actualiza el estado, eso provoca un render, que crea otro objeto… Soluciones, por orden de preferencia:

// ✅ 1. Mover el objeto DENTRO del efecto y depender de sus valores primitivos
useEffect(() => {
  const filtros = { tipo, estado: 'disponible' };
  buscarBicicletas(filtros).then(setResultados);
}, [tipo]);

// ✅ 2. Depender de los primitivos directamente
useEffect(() => {
  buscarBicicletas({ tipo, estado }).then(setResultados);
}, [tipo, estado]);

// ✅ 3. Si el objeto viene de fuera y no puedes cambiarlo, extrae lo que necesitas
const { tipo, estado } = filtros;
useEffect(() => {
  buscarBicicletas({ tipo, estado }).then(setResultados);
}, [tipo, estado]);

Causa 2: una función recreada en cada render

// ❌ BUCLE: 'cargar' es una función nueva en cada render
function PanelEstacion({ estacionId }) {
  function cargar() {
    fetch(`/api/estaciones/${estacionId}`).then((r) => r.json()).then(setDatos);
  }

  useEffect(() => { cargar(); }, [cargar]);
}

// ✅ Mover la función dentro del efecto: deja de ser una dependencia
function PanelEstacion({ estacionId }) {
  useEffect(() => {
    function cargar() {
      fetch(`/api/estaciones/${estacionId}`).then((r) => r.json()).then(setDatos);
    }
    cargar();
  }, [estacionId]);
}

Mover la función dentro del efecto es casi siempre la solución correcta, y tiene una ventaja añadida: el linter puede analizar de verdad qué usa la función y calcular las dependencias reales. useCallback también resuelve el caso, pero es una herramienta de memorización que se estudia en 08-03 y aquí sería usar un martillo para una chincheta.

Causa 3: el efecto actualiza el estado del que depende

// ❌ BUCLE clarísimo
useEffect(() => {
  setContador(contador + 1);
}, [contador]);

Si el objetivo es contar algo, casi nunca es trabajo de un efecto. Si de verdad lo fuera, la forma funcional permite quitar la dependencia:

useEffect(() => {
  setContador((previo) => previo + 1);
}, [estacionId]);   // depende del hecho que quieres contar, no del contador

  1. Antipatrones que hay que reconocer

Actualizar estado derivado con un efecto

// ❌ El clásico. Dos renders, riesgo de desincronización, ninguna ventaja
const [horas, setHoras] = useState(2);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(horas * bicicleta.precioHora);
}, [horas, bicicleta.precioHora]);

// ✅ Un cálculo durante el render
const total = horas * bicicleta.precioHora;

Encadenar efectos que se disparan entre sí

// ❌ Cuatro renders en cascada para una sola acción del usuario
useEffect(() => {
  if (bicicletaSeleccionada) setHoras(1);
}, [bicicletaSeleccionada]);

useEffect(() => {
  if (horas > 0) setTotal(horas * precio);
}, [horas, precio]);

useEffect(() => {
  if (total > 0) setPuedeConfirmar(true);
}, [total]);

Esta cadena es frágil (cambiar el orden lo rompe), lenta (un render por eslabón) e imposible de seguir al leerla. La versión correcta hace todo el trabajo en el manejador que originó la acción y deriva lo demás:

// ✅ Una acción del usuario, un manejador, un render
function manejarSeleccionBicicleta(bicicleta) {
  setBicicletaSeleccionada(bicicleta);
  setHoras(1);
}

// Derivados, en el cuerpo del componente
const total = bicicletaSeleccionada ? horas * bicicletaSeleccionada.precioHora : 0;
const puedeConfirmar = total > 0;

Inicializar la aplicación dentro de un efecto de componente

// ❌ Con StrictMode se ejecuta dos veces, y no depende del componente
function App() {
  useEffect(() => {
    comprobarSesionDeUsuario();
    cargarConfiguracionGlobal();
  }, []);
}

// ✅ Fuera de React, en el módulo: se ejecuta una vez al cargar la aplicación
if (typeof window !== 'undefined') {
  comprobarSesionDeUsuario();
  cargarConfiguracionGlobal();
}

function App() { … }

Enviar datos en un efecto en lugar de en el manejador

// ❌ Si el estado se restaura por cualquier motivo, la reserva se envía otra vez
useEffect(() => {
  if (datosFormulario) enviarReserva(datosFormulario);
}, [datosFormulario]);

// ✅ El envío pertenece al submit
function manejarEnvio(evento) {
  evento.preventDefault();
  enviarReserva(datos);
}

  1. useLayoutEffect y la carga de datos moderna

Existe una variante, useLayoutEffect, que se ejecuta después del commit pero antes de que el navegador pinte; se reserva para medir el DOM y ajustar la posición de algo sin que se vea el salto (un tooltip que debe caber en pantalla), y su uso indebido bloquea el pintado, así que la opción por defecto es siempre useEffect.

Y una advertencia sobre el apartado 9: aunque cargar datos con useEffect es perfectamente válido y conviene entenderlo a fondo —es lo que hay debajo de todo lo demás—, en una aplicación de producción no se escribe a mano. Faltan la caché entre pantallas, la deduplicación de peticiones simultáneas, los reintentos, la revalidación al volver a la pestaña y la invalidación tras una escritura. Todo eso lo resuelven bibliotecas de estado del servidor como TanStack Query o SWR, y también los cargadores de datos de los enrutadores (Módulo 6) y de los marcos como Next.js (Módulo 10). Es el tema de 07-06; aquí quédate con el mecanismo, que es el que te permitirá entender esas herramientas en lugar de usarlas a ciegas.

Errores Comunes y Consejos

  • Usar useEffect para transformar datos. El síntoma es un useState que solo se actualiza dentro de un efecto. Casi siempre es un valor derivado disfrazado.
  • Olvidar la limpieza. Temporizadores y escuchadores que sobreviven al componente son fugas de memoria y actualizaciones fantasma. Si el efecto arranca algo, la limpieza lo para.
  • Pasar una función distinta a removeEventListener. Guarda la función en una constante dentro del efecto y usa esa misma referencia en las dos llamadas.
  • Declarar la función de efecto como async. React interpretaría la promesa devuelta como función de limpieza. Declara una función interna y llámala.
  • Silenciar exhaustive-deps. El aviso señala un problema real; ocultarlo lo convierte en un error de producción.
  • Depender de un objeto o array creado en el render. Es la causa más común de bucle infinito. Depende de primitivos.
  • Quitar StrictMode porque «el efecto se ejecuta dos veces». El problema es la limpieza que falta, no StrictMode.
  • No comprobar respuesta.ok. fetch considera un 500 una respuesta perfectamente válida.
  • Consejo: antes de escribir un efecto, di en voz alta la frase «este componente debe mantenerse sincronizado con ___». Si no puedes completarla con un sistema externo, no es un efecto.
  • Consejo: un console.log al principio del efecto y otro en la limpieza, con el valor de la dependencia, resuelven la mayoría de las dudas sobre cuándo se ejecuta qué.

Ejercicios

Ejercicio 1. Este RelojEstacion de CicloUrbano —el que apareció en el ejercicio 2 de 04-03— tiene tres fallos relacionados con useEffect. Encuéntralos, explica qué provoca cada uno y escribe la versión corregida.

import { useState, useEffect } from 'react';

function RelojEstacion({ estacion, zonaHoraria }) {
  const [hora, setHora] = useState(new Date());
  const [horaFormateada, setHoraFormateada] = useState('');

  useEffect(() => {
    setInterval(() => setHora(new Date()), 1000);
  });

  useEffect(() => {
    setHoraFormateada(hora.toLocaleTimeString('es-ES', { timeZone: zonaHoraria }));
  }, [hora, zonaHoraria]);

  return <p>{estacion.nombre}: {horaFormateada}</p>;
}

Ejercicio 2. Escribe BuscadorBicicletas con retardo (debounce): el componente mantiene su estado local texto (04-01) y debe avisar al padre con alBuscar(texto) solo cuando el usuario lleve 400 ms sin escribir. Usa useEffect con limpieza. Explica por qué la limpieza es exactamente lo que produce el retardo.

Ejercicio 3. El siguiente componente carga la ficha de una estación y presenta una condición de carrera y una fuga. Reescríbelo con la estructura de estado del apartado 9 (una sola variable de fase), AbortController, bandera ignorar y manejo de errores HTTP.

function FichaEstacion({ estacionId }) {
  const [estacion, setEstacion] = useState(null);
  const [cargando, setCargando] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setCargando(true);
    fetch(`/api/estaciones/${estacionId}`)
      .then((r) => r.json())
      .then((datos) => { setEstacion(datos); setCargando(false); })
      .catch((e) => setError(e.message));
  }, [estacionId]);

  if (cargando) return <p>Cargando…</p>;
  if (error) return <p>{error}</p>;
  return <h2>{estacion.nombre}</h2>;
}

Soluciones

Solución 1.

Los tres fallos:

  1. El primer efecto no tiene array de dependencias: se ejecuta después de cada render. Como el propio efecto actualiza hora, cada tic provoca un render que crea otro temporizador. Al cabo de un minuto hay decenas corriendo a la vez y el reloj avanza a saltos.
  2. El primer efecto no tiene limpieza: aunque tuviera [], con StrictMode quedarían dos temporizadores, y al desmontar el componente el temporizador seguiría vivo llamando a setHora sobre un componente que ya no existe.
  3. El segundo efecto es estado derivado disfrazado: horaFormateada se calcula por completo a partir de hora y zonaHoraria. Sobra el estado y sobra el efecto; además provoca un render extra por segundo y hace que el primer render muestre una cadena vacía.
// src/componentes/RelojEstacion.jsx
import { useState, useEffect } from 'react';

/**
 * Props:
 *  - estacion     (objeto Estacion, obligatorio)
 *  - zonaHoraria  (cadena, opcional)
 */
function RelojEstacion({ estacion, zonaHoraria }) {
  const [hora, setHora] = useState(() => new Date());

  useEffect(() => {
    const identificador = setInterval(() => setHora(new Date()), 1000);
    return () => clearInterval(identificador);
  }, []);   // el temporizador no depende de nada: se crea una vez

  // DERIVADO: se recalcula en cada render, sin estado ni efecto
  const horaFormateada = hora.toLocaleTimeString('es-ES', { timeZone: zonaHoraria });

  return <p>{estacion.nombre}: {horaFormateada}</p>;
}

export default RelojEstacion;

Solución 2.

// src/componentes/BuscadorBicicletas.jsx
import { useState, useEffect } from 'react';

/**
 * Buscador con retardo.
 * Props:
 *  - alBuscar  (función, obligatoria): recibe el término tras 400 ms de inactividad
 *  - retardoMs (número, opcional, por defecto 400)
 */
function BuscadorBicicletas({ alBuscar, retardoMs = 400 }) {
  const [texto, setTexto] = useState('');

  useEffect(() => {
    const identificador = setTimeout(() => alBuscar(texto), retardoMs);
    return () => clearTimeout(identificador);
  }, [texto, retardoMs, alBuscar]);

  return (
    <input
      type="search"
      value={texto}
      onChange={(evento) => setTexto(evento.target.value)}
      aria-label="Buscar bicicletas por modelo"
    />
  );
}

export default BuscadorBicicletas;

Por qué la limpieza produce el retardo: cada pulsación cambia texto, así que React ejecuta primero la limpieza del efecto anterior —que cancela el temporizador pendiente— y luego el efecto nuevo, que programa otro a 400 ms. Mientras el usuario siga escribiendo, ningún temporizador llega a cumplirse: cada uno muere cancelado por la tecla siguiente. Solo cuando pasan 400 ms sin cambios sobrevive el último y llama a alBuscar. El retardo no está programado en ninguna parte: emerge del par efecto/limpieza.

Nota: alBuscar está en las dependencias porque el linter lo exige y es un valor reactivo. Si el padre la recrea en cada render, esto reiniciaría el temporizador constantemente; se resuelve con useCallback en el padre (08-03) o, mejor, extrayendo la lógica a un hook useDebounce, que es justo lo que harás en 05-06.

Solución 3.

function FichaEstacion({ estacionId }) {
  const [fase, setFase] = useState('cargando');   // 'cargando' | 'exito' | 'error'
  const [estacion, setEstacion] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controlador = new AbortController();
    let ignorar = false;

    async function cargar() {
      setFase('cargando');
      setError(null);
      try {
        const respuesta = await fetch(`/api/estaciones/${estacionId}`, {
          signal: controlador.signal
        });
        if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
        const datos = await respuesta.json();
        if (!ignorar) {
          setEstacion(datos);
          setFase('exito');
        }
      } catch (fallo) {
        if (fallo.name === 'AbortError') return;
        if (!ignorar) {
          setError(fallo.message);
          setFase('error');
        }
      }
    }

    cargar();
    return () => { ignorar = true; controlador.abort(); };
  }, [estacionId]);

  if (fase === 'cargando') return <p aria-live="polite">Cargando…</p>;
  if (fase === 'error') return <p role="alert">{error}</p>;
  return <h2>{estacion.nombre}</h2>;
}

Los problemas del original y su arreglo: no cancelaba la petición anterior al cambiar estacionId (condición de carrera); dejaba cargando en true para siempre si la petición fallaba, porque el .catch no lo apagaba (estado contradictorio, principio 3 de 05-01); trataba un 404 como éxito, porque fetch no lanza en respuestas HTTP de error; y podía intentar leer estacion.nombre con estacion a null. La variable de fase única elimina de raíz las combinaciones imposibles.

Conclusión

useEffect no es «código que se ejecuta al montar»: es la herramienta para sincronizar un componente con un sistema externo, con dos operaciones simétricas —empezar y dejar de sincronizar— que React repite tantas veces como haga falta. Has visto su anatomía (efecto, limpieza, dependencias), las tres formas del array y cuándo se dispara cada una, el ciclo real con la limpieza siempre emparejada al efecto, y por qué la doble ejecución de StrictMode es una prueba gratuita de que tu limpieza está bien hecha. Con CicloUrbano has escrito los cuatro casos legítimos: un temporizador de disponibilidad, una suscripción a eventos del navegador, la carga de datos con AbortController y bandera ignorar contra la condición de carrera, y la sincronización del título del documento. Y, sobre todo, has aprendido a no usarlo: transformar datos es render, responder a un clic es manejador, reiniciar estado es key, y los efectos encadenados son una cascada de renders que casi siempre esconde un derivado mal planteado.

Queda una pieza que ha aparecido de refilón varias veces. El temporizador del apartado 7 devolvía un identificador que había que guardar; el buscador del ejercicio 2 necesitaba recordar el temporizador pendiente; y en 03-05 se mencionó una forma de leer un campo del formulario accediendo directamente al nodo del DOM. Todo eso son valores que hay que recordar entre renders pero que no deben provocar ninguno, más la vía de escape controlada de React hacia los elementos reales de la página: dar el foco a un campo, medir un elemento, desplazar una lista, abrir un <dialog>. La próxima lección es Hook useRef y Acceso al DOM.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados