learning-front

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

Custom hooks y context

Empaquetar lógica reutilizable en tu propio hook y compartir estado entre componentes lejanos sin pasar props por toda la jerarquía.

En el capítulo anterior dejaste el Team Builder con un useEffect que carga los héroes: estado de carga, estado de error, promesa, cleanup. Ese patrón funciona. Pero en una app real lo repetirás en diez componentes distintos, y cada repetición es un sitio donde puede entrar un bug. Y, por otro lado, el botón de favorito de HeroCard recibía onToggle por props de App, que lo recibía de… ningún sitio, porque era el propio App. Cuando la cadena crezca, eso se convierte en un problema.

Este capítulo resuelve los dos: los custom hooks para empaquetar lógica repetida, y el context para compartir datos sin pasar props por toda la jerarquía. Y, como siempre, lo que más importa no es la sintaxis: es entender qué problema resuelve cada herramienta y cuándo no usarla.

Custom hooks: extraer para reutilizar#

Un custom hook es una función ordinaria de TypeScript con dos características:

  1. Su nombre empieza por use.
  2. Usa otros hooks dentro (useState, useEffect, los que necesite).

Eso es todo. No hay ninguna API especial de React que invocar. La magia es que, por empezar por use, React y el linter saben que esa función contiene hooks y le aplican las mismas reglas: no puedes llamarla dentro de un if, un bucle o después de un return.

¿Por qué importa el nombre? Si lo llamas obtenerHeroes en vez de useHeroes, el linter ya no lo vigila. Puedes llamarlo desde donde quieras, romper el orden de los hooks y no enterarte hasta que algo explota en producción. El prefijo use es la señal que activa esa red de seguridad.

Aquí está la lógica de carga del capítulo anterior, extraída a un hook:

tsx
function useHeroes() {
  const [heroes, setHeroes] = useState<Heroe[]>([]);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState(false);

  // [] = corre una sola vez al montar, igual que en el capítulo anterior.
  useEffect(() => {
    cargarHeroes()
      .then((datos) => setHeroes(datos))
      .catch(() => setError(true))
      .finally(() => setCargando(false));
  }, []);

  // Devuelve exactamente lo que el componente necesita.
  return { heroes, cargando, error };
}

Y el componente que lo usa:

tsx
export default function App() {
  // Una línea en vez de tres useState + un useEffect.
  const { heroes, cargando, error } = useHeroes();
  // ... JSX igual que antes
}

App ya no sabe nada de promesas ni de efectos: esa responsabilidad está dentro del hook. Si mañana cambias cargarHeroes por un fetch real, solo tocas useHeroes; App no se entera.

¿Qué puede devolver un custom hook?#

Lo que quieras: un objeto, un array, un valor simple, nada. El contrato lo decides tú. Lo habitual es devolver un objeto con los datos y las funciones que el componente necesita:

tsx
// Devuelve un objeto: puedes elegir qué usar con desestructuración.
return { heroes, cargando, error };

// Un array también es válido (útil si el consumidor quiere renombrar).
return [heroes, cargando, error] as const;

El objeto es más legible cuando devuelves varias cosas; el array es útil si quieres que el consumidor renombre con facilidad (como hace useState).

Prop drilling: el problema que context resuelve#

En el capítulo 5, el estado de favoritos vivía en App y bajaba a HeroCard por props:

tsx
// App tiene el estado y pasa el callback hacia abajo.
const [favoritos, setFavoritos] = useState<number[]>([]);
<HeroCard hero={hero} esFavorito={favoritos.includes(hero.id)} onToggle={toggleFavorito} />

Con dos niveles eso es manejable. Pero imagina que la jerarquía crece:

texto
App
  └─ EquipoPanel (no usa favoritos, solo los pasa)
       └─ EquipoGrid (tampoco los usa, solo los pasa)
            └─ HeroCard (aquí sí los necesita)

EquipoPanel y EquipoGrid tienen que recibir favoritos y onToggle solo para pasarlos. No los usan. Eso es prop drilling: pasar datos por componentes intermedios que no los necesitan, solo para que lleguen abajo. A medida que el árbol crece, el drilling crece con él, y cada componente intermedio se convierte en un cable que alguien puede cortar sin querer.

Context: un canal directo por el árbol#

El context es la solución de React para este problema. En vez de pasar el dato por cada nivel, lo pones en un “canal” accesible para todos los descendientes:

tsx
// 1. Creas el context. El null es el valor fuera de cualquier Provider.
const FavoritosContext = createContext<FavoritosContextType | null>(null);

// 2. El Provider rodea el árbol y pone el valor disponible.
<FavoritosContext.Provider value={{ favoritos, toggleFavorito }}>
  <EquipoPanel />
</FavoritosContext.Provider>

// 3. HeroCard, hondo en el árbol, lo lee con useContext. Sin props intermedias.
const ctx = useContext(FavoritosContext);

EquipoPanel y EquipoGrid no saben que existe el context. HeroCard lo lee directamente. El prop drilling desaparece.

Las tres piezas del context#

Siempre son tres:

  1. createContext — crea el canal. Se llama una vez, fuera de los componentes. Recibe el valor por defecto (lo que hay si no hay ningún Provider arriba; normalmente null).
  2. <MiContext.Provider value={...}> — pone el valor en el canal. Rodea la parte del árbol que necesita acceso. El value puede ser cualquier cosa: un objeto, un string, una función.
  3. useContext(MiContext) — lee el valor desde cualquier descendiente del Provider.

Cuándo NO usar context#

Context no es un gestor de estado global para todo. Hay dos límites claros:

El primero: todos los consumidores re-renderizan cuando el value cambia. Si pasas como value un objeto literal creado en el render de App:

tsx
// Cada vez que App re-renderiza, este objeto es UNO NUEVO (diferente referencia).
// Todos los consumidores del context re-renderizan, aunque el contenido sea igual.
<FavoritosContext.Provider value={{ favoritos, toggleFavorito }}>

Para state pequeño y local (como favoritos en este Team Builder) eso no es un problema. Pero si el context lo consumen cincuenta componentes y el Provider está en la raíz de la app, un re-render del root los re-renderiza a todos. Para estado muy global hay herramientas más eficientes (que se ven en capítulos posteriores).

El segundo: context no reemplaza al estado; solo lo hace accesible. El estado sigue viviendo donde lo creaste con useState. Context es el tubo; tú pones el agua.

Pruébalo#

Demo 1: custom hook. El Team Builder usa useHeroes(). Observa cómo App no tiene ni un useState ni un useEffect de carga: toda esa lógica está dentro del hook.

Demo 2: context. Aquí HeroCard alterna favoritos con el botón sin recibir ninguna prop de App. El value del Provider lo lee directamente con useContext.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Por qué un custom hook DEBE empezar por `use`?

Tu turno#

Ejercicio · en esta página

Custom hook y context para los favoritos

Extrae la lógica de carga a useHeroes() y usa FavoritosContext para que HeroCard pueda alternar favoritos sin recibir props de App.

Paso 1: Custom hook funcionando

  • useHeroes() devuelve { heroes, cargando, error }
  • App usa useHeroes() y queda limpio: sin useState ni useEffect de carga directos
  • El grid pinta los héroes con su winrate, con los estados de carga y error
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: promesa que resuelve tras un momento.
function cargarHeroes(): Promise<Heroe[]> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(HEROES), 800);
  });
}

// El custom hook extrae la lógica de carga fuera del componente.
// Empieza por "use": así React y el linter saben que hay hooks dentro y aplican las reglas.
function useHeroes() {
  // Estado: los héroes cargados, si se está cargando y si hubo error.
  const [heroes, setHeroes] = useState<Heroe[]>([]);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState(false);

  // Efecto: pide los héroes al montar ([] = una sola vez).
  useEffect(() => {
    cargarHeroes()
      .then((datos) => setHeroes(datos))
      .catch(() => setError(true))
      .finally(() => setCargando(false));
  }, []);

  // Devuelve lo que el componente necesita saber: nada más.
  return { heroes, cargando, error };
}

// HeroCard pinta la tarjeta de un héroe.
function HeroCard({ hero }: { hero: Heroe }) {
  // El winrate se calcula a partir de los datos; no se guarda en estado.
  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() {
  // Una línea en vez de tres useState + un useEffect: la lógica está en el hook.
  const { heroes, cargando, error } = useHeroes();

  return (
    <section>
      <h1 className="titulo">Team Builder</h1>
      {error && <p className="error">No se han podido cargar los héroes.</p>}
      {cargando && !error && <p className="cargando">Cargando héroes...</p>}
      {!cargando && !error && (
        <div className="equipo">
          {heroes.map((hero) => (
            <HeroCard key={hero.id} hero={hero} />
          ))}
        </div>
      )}
    </section>
  );
}

Por qué este nivel

  • useHeroes() tiene una línea de retorno clara: { heroes, cargando, error }. App no sabe nada de useEffect ni de useState de carga: esa complejidad está dentro del hook. Si mañana cambias la fuente (fetch real en vez de promesa simulada), solo tocas el hook; App no se entera.