learning-front

Nivel 6 · 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]);

  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 suele ser un bug).

¿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.

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.

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: valores que aparecen donde no toca, o un error directo. 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 dedicada a vigilarlo.

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.