learning-front

Nivel 7 · React de cero a héroe (con TypeScript)

Hooks fundamentales

useEffect para sincronizar con el exterior (cargar datos, con su array de dependencias y su limpieza), useRef para tocar el DOM sin repintar, y por qué los hooks tienen reglas tan estrictas.

El Team Builder ya reacciona al usuario, pero sus héroes siguen escritos en el código. En una app de verdad los datos vienen de fuera: de una API, del almacenamiento del navegador, de un temporizador. Conectar tu componente con ese mundo exterior es el trabajo de useEffect. Y, como en todo React, lo que de verdad importa no es la sintaxis, sino entender cuándo y por qué se usa, porque es de los hooks que más se usan mal.

Qué es un efecto secundario#

El trabajo de un componente es devolver JSX a partir de sus props y su estado. Nada más. Eso lo hace predecible: con los mismos datos, pinta lo mismo. A esa pureza React le saca mucho partido.

Pero una app tiene que hacer cosas además de pintar: pedir datos a un servidor, suscribirse a un evento, cambiar el título de la pestaña, arrancar un temporizador. Todo eso toca el mundo de fuera de React y se llama efecto secundario. Esas cosas no pueden ir en el cuerpo del componente: se ejecutarían en cada render, en mitad del pintado, antes incluso de que el DOM exista. useEffect es el sitio donde React te deja meterlas, de forma controlada y después de pintar.

useEffect y el array de dependencias#

useEffect recibe dos cosas: una función (el efecto) y un array de dependencias que decide cuándo se vuelve a ejecutar.

tsx
import { useEffect, useState } from "react";

function Titulo() {
  const [cuenta, setCuenta] = useState(0);

  // El efecto sincroniza el título de la pestaña con la cuenta.
  // Corre después de pintar, y se REPITE solo cuando cambia algo de [cuenta].
  useEffect(() => {
    document.title = "Llevas " + cuenta;
  }, [cuenta]);

  // Botón que incrementa la cuenta; cada clic dispara un re-render y el efecto se repite.
  return <button onClick={() => setCuenta(cuenta + 1)}>Sumar</button>;
}

El array es la clave, y cada forma significa una cosa distinta:

  • [cuenta] — el efecto se repite cuando cuenta cambia. Es lo más común.
  • [] — sin dependencias que puedan cambiar, corre una sola vez, al montar. Ideal para cargar datos al entrar.
  • Sin array — corre en cada render. Casi nunca es lo que quieres, y en carga de datos es un bug grave: el efecto llama a setHeroes → ese cambio de estado provoca un re-render → el efecto vuelve a correr → vuelve a llamar a la API → y así sin fin. La app se congela y bombardea el servidor con peticiones.

¿Y por qué importa tanto acertar con las dependencias? Porque el efecto “recuerda” los valores que había cuando se ejecutó. Si usas cuenta dentro pero no la pones en el array, el efecto se queda con la cuenta del primer render —congelada en 0— y nunca se entera de los cambios. Por eso la regla: todo lo que el efecto use de dentro del componente va en las dependencias. Hay un plugin del linter (Nivel 4) que te avisa si te dejas alguna.

Ejemplo de cómo se ve ese bug. Fíjate en el comentario del console.log:

tsx
function Contador() {
  const [cuenta, setCuenta] = useState(0);

  // [] = solo corre al montar. El efecto captura "cuenta" del primer render.
  useEffect(() => {
    // cuenta aquí siempre es 0: la capturó al montar y nunca la actualizó.
    // Aunque pulses el botón diez veces, el log sigue diciendo "Cuenta: 0".
    console.log("Cuenta: " + cuenta);
  }, []);

  // Botón que incrementa la cuenta, pero el efecto no se entera.
  return <button onClick={() => setCuenta(cuenta + 1)}>Sumar ({cuenta})</button>;
}

La solución es poner cuenta en las dependencias: useEffect(() => { ... }, [cuenta]). El efecto se vuelve a ejecutar cuando cuenta cambia y ya ve el valor real. El linter lo detecta y avisa; nunca ignores ese aviso.

Cargar datos al montar#

El uso estrella: traer datos al entrar en la pantalla. La forma se repite siempre —tres estados (datos, cargando, error) y un efecto que los rellena—:

tsx
// (cargarHeroes simula la red con una promesa; en el capítulo de data-fetching
//  lo harás con fetch de verdad. La mecánica del efecto es idéntica.)
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(false);

// [] = una vez al montar. Pide los datos y reparte el resultado por el estado.
useEffect(() => {
  cargarHeroes()
    .then((datos) => setHeroes(datos))
    .catch(() => setError(true))
    .finally(() => setCargando(false));
}, []);

Con una API real, la llamada es la misma sustituyendo cargarHeroes() por fetch:

tsx
// En un proyecto real (lo verás a fondo en data-fetching):
useEffect(() => {
  fetch("https://api.tuequipo.dev/heroes")
    .then((res) => res.json())
    .then((datos) => setHeroes(datos))
    .catch(() => setError(true))
    .finally(() => setCargando(false));
}, []);

La función de limpieza#

Un efecto que lanza una petición o se suscribe a algo deja un cabo suelto. Para recogerlo, el efecto puede devolver una función: la limpieza, que React ejecuta antes de volver a correr el efecto y al desmontar el componente.

¿Por qué hace falta? Imagina que el usuario pulsa “Recargar” dos veces seguidas. Se lanzan dos peticiones. Si la primera (vieja) tarda más que la segunda (nueva), llegará la última y pisará los datos buenos con los viejos: un bug de condición de carrera, silencioso y dificilísimo de reproducir. La limpieza lo corta:

tsx
useEffect(() => {
  // Bandera local de este efecto.
  let ignorar = false;

  cargarHeroes().then((datos) => {
    // Si este efecto ya fue "limpiado", su respuesta se descarta.
    if (!ignorar) {
      setHeroes(datos);
    }
  });

  // Limpieza: marca esta ejecución como obsoleta.
  return () => {
    ignorar = true;
  };
}, [intento]);

Con fetch real, la limpieza ideal cancela la petición con un AbortController (lo viste en Nivel 3), no solo ignora su resultado. La idea es la misma: no dejar trabajo viejo corriendo por detrás.

Cuándo NO usar useEffect#

Aquí está el error más común de quien empieza con React: meter en useEffect cosas que no son efectos. Dos casos a evitar:

  • Datos derivados. Si un valor se puede calcular a partir del estado o las props (el winrate, la lista filtrada), se calcula en el cuerpo del componente, no se guarda en un estado aparte que rellenas con un efecto. Guardar lo que puedes derivar es la primera fuente de bugs por desincronización (lo viste en el capítulo anterior).
  • Reaccionar a un evento. Si algo pasa porque el usuario hizo clic, va en el manejador del evento (onClick), no en un efecto que vigile un estado. El efecto es para sincronizar con el exterior, no para encadenar acciones del usuario.

La regla mental: usa useEffect solo cuando necesites sincronizar con algo de fuera de React.

useRef: recordar sin repintar#

A veces necesitas guardar un valor que sobrevive entre renders pero cuyo cambio no debe provocar un repintado. Para eso no sirve useState (cambiarlo repinta). Sirve useRef.

Su uso más habitual es acceder a un nodo del DOM: le pasas la ref a un elemento con ref={...} y, cuando React lo monta, deja el nodo en ref.current.

tsx
import { useEffect, useRef } from "react";

function Buscador() {
  // Una "caja" cuyo .current React rellenará con el nodo del input.
  const inputRef = useRef<HTMLInputElement>(null);

  // Al montar, enfocamos el input. current puede ser null antes de montar.
  useEffect(() => {
    if (inputRef.current) {
      // preventScroll: enfoca SIN que el navegador arrastre la página hasta el input.
      inputRef.current.focus({ preventScroll: true });
    }
  }, []);

  // ref={inputRef} conecta el nodo real con nuestra caja.
  return <input ref={inputRef} type="text" />;
}

Fíjate en el { preventScroll: true }. Un autofoco normal hace que el navegador desplace la página hasta el input para enseñarlo, y eso —en una página larga, o dentro de un recuadro como el del propio curso— da un tirón desconcertante que te saca de donde estabas leyendo. Con esa opción enfocas igual, pero sin mover la página.

¿Por qué con useRef y no con estado? Porque el foco, o guardar el id de un temporizador, son cosas “entre bambalinas” que no cambian lo que se pinta. Si usaras estado, cada una provocaría un re-render innecesario. La diferencia, en una frase: el estado es para datos que se ven; la ref, para datos que no se ven pero hay que recordar.

useRef tiene un segundo uso igual de habitual: guardar un valor mutable entre renders sin provocar un repintado. El caso típico es guardar el identificador de un temporizador para poder limpiarlo:

tsx
import { useEffect, useRef } from "react";

function Reloj() {
  // idRef guarda el id del intervalo entre renders.
  // Cambiar .current NO provoca re-render, a diferencia de setState.
  const idRef = useRef<ReturnType<typeof setInterval> | null>(null);

  useEffect(() => {
    // Arrancamos el intervalo y guardamos su id en la ref.
    idRef.current = setInterval(() => {
      console.log("tick");
    }, 1000);

    // Limpieza: cuando el componente se desmonta, cancelamos el intervalo.
    // Sin esto el intervalo seguiría corriendo aunque el componente ya no exista.
    return () => {
      if (idRef.current !== null) {
        clearInterval(idRef.current);
      }
    };
  }, []);

  // El componente no pinta el id: es solo una referencia de trabajo interna.
  return <p>Reloj en marcha (mira la consola).</p>;
}

Si guardaras el id en un useState, cada setId(...) causaría un re-render innecesario. La ref almacena el valor de forma silenciosa: persiste entre renders, pero no fuerza ningún pintado.

Las reglas de los hooks#

Todos los hooks comparten dos reglas que parecen arbitrarias y no lo son:

  1. Solo en el nivel superior del componente. Nunca dentro de un if, un bucle o después de un return.
  2. Siempre en el mismo orden en cada render.

¿Por qué tan estrictas? Porque React no ve los nombres de tus hooks: los identifica por el ORDEN en que los llamas. El primer useState que ejecutas es “el estado nº 1”, el segundo es “el nº 2”, y así. React guarda esos estados en una lista y, en cada render, los va repartiendo en ese mismo orden.

Si metes un hook dentro de un if, rompes el orden:

tsx
// MAL: el useState de dentro del if a veces se llama y a veces no.
function Perfil({ logueado }: { logueado: boolean }) {
  if (logueado) {
    // En el render donde logueado es true, este es "el hook nº 1".
    // En el render donde es false, no se llama: el orden se descuadra.
    const [nombre, setNombre] = useState("");
  }
  // ...
}

El render en que logueado cambia, el recuento de hooks cambia, y React acaba entregando el estado de un hook a otro. ¿Y qué ves cuando pasa? Depende de dónde. En desarrollo, React suele detectarlo y lanzar un error claro (Rendered fewer hooks than expected) que al menos te señala que el problema son los hooks. En producción puede ser peor: que no casque y, simplemente, el estado de un hook quede asignado al siguiente —un valor “salta” de un campo a otro, aparece donde no toca— sin ningún aviso. Ese fallo silencioso es el que cuesta horas encontrar, porque parece un bug de tu lógica y en realidad es el orden de los hooks descuadrado. Por eso los hooks van siempre arriba, antes de cualquier if o return, y las condiciones van dentro del efecto o del JSX, no envolviendo al hook. Hay una regla del linter (react-hooks/rules-of-hooks) dedicada a vigilarlo: hazle caso.

Pruébalo#

El Team Builder pide sus héroes a la “API” al montar (verás el “Cargando…” un instante), y el buscador se enfoca solo. Cambia el setTimeout o los datos y vuelve a ejecutar.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Cuándo se ejecuta el código de un useEffect?

Tu turno#

Ejercicio · en esta página

Cargar los héroes de la API con useEffect

Usa useEffect para cargar los héroes con cargarHeroes() al montar, mostrando un estado de carga, y enfoca el buscador con useRef.

Paso 1: Carga al montar

  • El estado guarda los héroes y si se está cargando
  • Un useEffect con [] llama a cargarHeroes() al montar y guarda el resultado
  • Se ve 'Cargando...' y luego el grid de héroes
Ver soluciones
import { useEffect, useState } from "react";

interface Heroe {
  id: number;
  nombre: string;
  rol: string;
  partidas: number;
  victorias: number;
}

const HEROES: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
  { id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
  { id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
  { id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
  { id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];

// Simula la API: una promesa que resuelve tras un momento.
function cargarHeroes(): Promise<Heroe[]> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(HEROES), 800);
  });
}

// HeroCard recibe un héroe por props y pinta su tarjeta.
function HeroCard({ hero }: { hero: Heroe }) {
  // El winrate es un dato derivado del héroe.
  const winrate = (hero.victorias / hero.partidas) * 100;
  return (
    <article className="tarjeta">
      <h2 className="tarjeta__nombre">{hero.nombre}</h2>
      <p className="tarjeta__rol">{hero.rol}</p>
      <p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
    </article>
  );
}

export default function App() {
  // Estado: los héroes cargados y si seguimos cargando.
  const [heroes, setHeroes] = useState<Heroe[]>([]);
  const [cargando, setCargando] = useState(true);

  // Efecto: al montar ([] = una sola vez), pide los héroes a la "API".
  // Corre DESPUÉS del primer pintado; por eso primero se ve "Cargando...".
  useEffect(() => {
    cargarHeroes().then((datos) => {
      setHeroes(datos);
      setCargando(false);
    });
  }, []);

  return (
    <section>
      <h1 className="titulo">Team Builder</h1>
      {/* Mientras carga, un aviso; cuando hay datos, el grid. */}
      {cargando ? (
        <p className="cargando">Cargando héroes...</p>
      ) : (
        <div className="equipo">
          {heroes.map((hero) => (
            <HeroCard key={hero.id} hero={hero} />
          ))}
        </div>
      )}
    </section>
  );
}

Por qué este nivel

  • El componente ya no nace con los datos puestos: los PIDE. useEffect con [] corre una vez al montar, tras el primer pintado, por eso se ve 'Cargando...' antes que el grid. El estado de carga no es un adorno: sin él, el usuario ve una pantalla vacía y no sabe si va o se ha roto.