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:
- Su nombre empieza por
use. - 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:
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:
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:
// 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:
// 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:
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:
// 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:
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; normalmentenull).<MiContext.Provider value={...}>— pone el valor en el canal. Rodea la parte del árbol que necesita acceso. Elvaluepuede ser cualquier cosa: un objeto, un string, una función.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:
// 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
Paso 2: Context para los favoritos
- FavoritosContext creado con createContext y un Provider en App
- HeroCard lee favoritos y toggleFavorito del context con useContext, sin recibirlos por props
- El botón de favorito usa el símbolo ★ y la clase favorito--activo cuando está activo
Paso 3: Hook auxiliar y componente hondo
- Un hook useFavoritos() que envuelve useContext y lanza un error descriptivo si falta el Provider
- Un componente ContadorFavoritos hondo en el árbol que lee el context por sí solo
- Limpieza del efecto con bandera ignorar; aguanta a 375px
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.
import { createContext, useContext, 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 },
];
function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(HEROES), 800);
});
}
// El custom hook de carga: devuelve { heroes, cargando, error }.
function useHeroes() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
cargarHeroes()
.then((datos) => setHeroes(datos))
.catch(() => setError(true))
.finally(() => setCargando(false));
}, []);
return { heroes, cargando, error };
}
// Tipo del valor que el context va a proveer a sus descendientes.
interface FavoritosContextType {
favoritos: number[];
toggleFavorito: (id: number) => void;
}
// createContext crea el "canal". El valor inicial es null: solo existe dentro del Provider.
const FavoritosContext = createContext<FavoritosContextType | null>(null);
// HeroCard NO recibe onToggle por props: lo lee directamente del context.
function HeroCard({ hero }: { hero: Heroe }) {
// useContext lee el valor que puso el Provider más cercano en el árbol.
const ctx = useContext(FavoritosContext);
// Si alguien usa HeroCard fuera del Provider, el context es null: avisamos en desarrollo.
if (!ctx) throw new Error("HeroCard debe estar dentro de FavoritosContext.Provider");
const { favoritos, toggleFavorito } = ctx;
const winrate = (hero.victorias / hero.partidas) * 100;
// esFavorito es un dato derivado: se calcula, no se guarda en estado.
const esFavorito = favoritos.includes(hero.id);
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>
{/* El botón toma la clase del context, sin que App le pase nada. */}
<button
className={esFavorito ? "favorito favorito--activo" : "favorito"}
onClick={() => toggleFavorito(hero.id)}
>
★
</button>
</article>
);
}
export default function App() {
const { heroes, cargando, error } = useHeroes();
// Estado de favoritos: un array de ids. Vive en App porque es el dueño natural.
const [favoritos, setFavoritos] = useState<number[]>([]);
// Toggle inmutable: siempre un array nuevo para que React detecte el cambio.
function toggleFavorito(id: number) {
setFavoritos((favs) =>
// Si ya estaba, lo quitamos (filter devuelve uno nuevo); si no, lo añadimos (spread).
favs.includes(id) ? favs.filter((fav) => fav !== id) : [...favs, id],
);
}
return (
// El Provider pone el valor disponible para TODOS los descendientes,
// sin importar cuántos niveles haya entre ellos y App.
<FavoritosContext.Provider value={{ favoritos, toggleFavorito }}>
<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 ya no necesita props de favoritos: los lee del context.
<HeroCard key={hero.id} hero={hero} />
))}
</div>
)}
</section>
</FavoritosContext.Provider>
);
} Por qué es mejor que el anterior
- Fíjate en el .map de heroes: ya no hay onToggle={toggleFavorito} ni esFavorito={...}. HeroCard se sirve solo. Eso es el context en acción: en cap.5 HeroCard recibía onToggle por props; ahora lo lee del canal sin que nadie se lo pase.
- El estado de favoritos sigue en App porque App es el dueño natural: controla cuándo se monta el Provider y qué valor ofrece. Context no mueve el estado a ningún sitio mágico; solo lo hace accesible sin pasar por props.
import { createContext, useContext, 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 },
];
function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(HEROES), 800);
});
}
// ── Custom hook de carga ────────────────────────────────────────────────────
function useHeroes() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
// Bandera de limpieza: si el componente se desmonta antes de que resuelva
// la promesa, no actualizamos el estado (evita el warning de React).
let ignorar = false;
setCargando(true);
setError(false);
cargarHeroes()
.then((datos) => {
if (!ignorar) setHeroes(datos);
})
.catch(() => {
if (!ignorar) setError(true);
})
.finally(() => {
if (!ignorar) setCargando(false);
});
// La función de limpieza activa la bandera cuando el efecto "muere".
return () => {
ignorar = true;
};
}, []);
return { heroes, cargando, error };
}
// ── Context de favoritos ────────────────────────────────────────────────────
interface FavoritosContextType {
favoritos: number[];
toggleFavorito: (id: number) => void;
}
const FavoritosContext = createContext<FavoritosContextType | null>(null);
// Hook auxiliar que envuelve useContext y lanza un error descriptivo si falta el Provider.
// Así el error sale en el sitio correcto, no en las entrañas de React.
function useFavoritos(): FavoritosContextType {
const ctx = useContext(FavoritosContext);
if (!ctx) throw new Error("useFavoritos debe llamarse dentro de FavoritosContext.Provider");
return ctx;
}
// ── Componentes ─────────────────────────────────────────────────────────────
// Contador de favoritos: componente hondo que NO recibe props del padre.
// Lee el context directamente con el hook auxiliar.
function ContadorFavoritos() {
const { favoritos } = useFavoritos();
// Sólo pintamos algo si hay al menos un favorito.
if (favoritos.length === 0) return null;
return (
<p className="cargando">
{favoritos.length === 1 ? "1 favorito seleccionado" : favoritos.length + " favoritos seleccionados"}
</p>
);
}
function HeroCard({ hero }: { hero: Heroe }) {
const { favoritos, toggleFavorito } = useFavoritos();
const winrate = (hero.victorias / hero.partidas) * 100;
const esFavorito = favoritos.includes(hero.id);
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>
<button
className={esFavorito ? "favorito favorito--activo" : "favorito"}
onClick={() => toggleFavorito(hero.id)}
>
★
</button>
</article>
);
}
// ── App ──────────────────────────────────────────────────────────────────────
export default function App() {
const { heroes, cargando, error } = useHeroes();
const [favoritos, setFavoritos] = useState<number[]>([]);
function toggleFavorito(id: number) {
setFavoritos((favs) =>
favs.includes(id) ? favs.filter((fav) => fav !== id) : [...favs, id],
);
}
return (
<FavoritosContext.Provider value={{ favoritos, toggleFavorito }}>
<section>
<h1 className="titulo">Team Builder</h1>
{/* ContadorFavoritos está HONDO: lee el context sin que nadie le pase props. */}
<ContadorFavoritos />
{error && <p className="error">No se han podido cargar los héroes.</p>}
{cargando && !error && <p className="cargando">Cargando héroes...</p>}
{!cargando && !error && heroes.length === 0 && (
<p className="equipo-vacio">No hay héroes disponibles.</p>
)}
{!cargando && !error && heroes.length > 0 && (
<div className="equipo">
{heroes.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
)}
</section>
</FavoritosContext.Provider>
);
} Por qué es mejor que el anterior
- useFavoritos() centraliza la guardia del null. Si alguien usa HeroCard o ContadorFavoritos fuera del Provider, el error llega con un mensaje que dice exactamente qué falta, en vez de un fallo críptico de React más adelante.
- ContadorFavoritos existe para mostrar el patrón: un componente que no recibe ninguna prop de App pero participa en el estado de favoritos. Así de lejos puede llegar el context sin prop drilling.