Módulo 12: Hooks personalizados
Objetivos del módulo
- Detectar lógica repetida entre componentes y extraerla a un hook propio
- Aplicar las convenciones de nombre, firma y valor devuelto, y saber por qué existen
- Entender que un hook comparte lógica, no estado
- Dominar
useSyncExternalStorepara leer datos que viven fuera de React - Construir paso a paso
useOnlineStatus,useFetch,useDebounce,useLocalStorageyuseMediaQuery - Componer hooks genéricos en hooks de negocio para la aplicación EMPRESA
- Recibir funciones en un hook sin caer en cierres obsoletos, con
useEffectEvento con una ref - Devolver funciones y objetos estables cuando importa, y etiquetar hooks con
useDebugValue - Probar hooks de forma aislada con
renderHook,act,waitFory temporizadores falsos
1. Por qué extraer lógica
React trae de serie hooks como useState, useEffect o useContext. Cada uno resuelve un mecanismo básico. Tu aplicación, en cambio, habla de otras cosas: “¿hay conexión?”, “busca empleados”, “recuerda las preferencias del usuario”. Un hook personalizado (custom hook) es una función tuya que usa hooks de React por dentro y le da un nombre a una de esas ideas.
Fíjate en esta pantalla de EMPRESA. La barra de estado y el botón de guardar quieren saber lo mismo, si el navegador tiene conexión, y cada uno se lo monta por su cuenta:
// src/App.tsx
import { useEffect, useState } from 'react';
function BarraEstado() {
const [enLinea, setEnLinea] = useState(navigator.onLine);
useEffect(() => {
const alConectar = () => setEnLinea(true);
const alDesconectar = () => setEnLinea(false);
window.addEventListener('online', alConectar);
window.addEventListener('offline', alDesconectar);
return () => {
window.removeEventListener('online', alConectar);
window.removeEventListener('offline', alDesconectar);
};
}, []);
return <p>{enLinea ? 'Conectado' : 'Sin conexión'}</p>;
}
function BotonGuardar() {
// ❌ Las mismas ocho líneas, copiadas y pegadas
const [enLinea, setEnLinea] = useState(navigator.onLine);
useEffect(() => {
const alConectar = () => setEnLinea(true);
const alDesconectar = () => setEnLinea(false);
window.addEventListener('online', alConectar);
window.addEventListener('offline', alDesconectar);
return () => {
window.removeEventListener('online', alConectar);
window.removeEventListener('offline', alDesconectar);
};
}, []);
return <button disabled={!enLinea}>Guardar empleado</button>;
}
export default function App() {
return (
<section className="tarjeta">
<BarraEstado />
<BotonGuardar />
</section>
);
}
El efecto se estudió en el módulo 09: se suscribe a los eventos online y offline de window y se da de baja al limpiar. El código funciona, pero tiene tres problemas:
- Está repetido. Si un día corriges un fallo, tienes que acordarte de cada copia.
- Mezcla niveles.
BotonGuardartrata de guardar empleados, pero la mayor parte de su código habla deaddEventListener. - No se puede probar aparte. Para comprobar la suscripción hay que montar un botón y una barra.
Extraer la lógica consiste en mover esas líneas a una función cuyo nombre empiece por use:
// src/hooks/useOnlineStatus.ts
import { useEffect, useState } from 'react';
export function useOnlineStatus() {
const [enLinea, setEnLinea] = useState(navigator.onLine);
useEffect(() => {
const alConectar = () => setEnLinea(true);
const alDesconectar = () => setEnLinea(false);
window.addEventListener('online', alConectar);
window.addEventListener('offline', alDesconectar);
return () => {
window.removeEventListener('online', alConectar);
window.removeEventListener('offline', alDesconectar);
};
}, []);
return enLinea;
}
Los componentes se quedan solo con lo que les interesa, que es qué hacer con el dato:
// src/App.tsx
import { useOnlineStatus } from './hooks/useOnlineStatus';
function BarraEstado() {
const enLinea = useOnlineStatus();
return (
<p className={enLinea ? 'aviso ok' : 'aviso error'}>
{enLinea ? 'Conectado a la red de EMPRESA' : 'Sin conexión: los cambios no se guardarán'}
</p>
);
}
function BotonGuardar() {
const enLinea = useOnlineStatus();
return <button disabled={!enLinea}>{enLinea ? 'Guardar empleado' : 'Reconectando...'}</button>;
}
export default function App() {
return (
<section className="tarjeta">
<h2>Ficha de empleado</h2>
<BarraEstado />
<BotonGuardar />
</section>
);
}
Al cortar la red en el navegador, los dos componentes cambian a la vez. La captura está hecha simulando la desconexión con setOffline de Playwright, que dispara el evento offline de verdad. Tú puedes conseguir lo mismo en la pestaña Network de las herramientas de desarrollo, eligiendo Offline.

Figura 1. BarraEstado y BotonGuardar reaccionan al mismo evento mediante el mismo hook.
Figura 2. El JSX sigue siendo de cada componente. Lo que se extrae es la lógica con estado.
📘 Concepto: Un hook personalizado es una función normal de JavaScript que llama a otros hooks. React no tiene ningún registro de hooks propios ni ninguna API para crearlos: para React, las llamadas a
useStateyuseEffectdeuseOnlineStatusson simplemente llamadas hechas durante el render deBarraEstado, como si las hubieras escrito dentro de ese componente.
Esta última idea explica casi todo lo que viene después. Un hook no es un componente, no renderiza nada y no tiene instancia propia. Se ejecuta dentro del componente que lo llama, en cada render.
Prepara el proyecto
Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-12 -- --template react-ts
cd modulo-12
npm install
npm run dev
Guarda los hooks en src/hooks/, un fichero por hook. Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas:
/* src/index.css */
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 640px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
label {
display: block;
margin: 0.4rem 0;
}
input,
select,
button {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid #9fa8da;
border-radius: 6px;
}
button {
background: #3f51b5;
color: #fff;
cursor: pointer;
}
button:disabled {
background: #b0bec5;
border-color: #b0bec5;
cursor: not-allowed;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 0.5rem;
}
th,
td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid #e8eaf6;
}
.aviso {
padding: 0.4rem 0.75rem;
border-radius: 6px;
margin: 0 0 0.75rem;
}
.ok {
background: #e8f5e9;
color: #1b5e20;
}
.error {
background: #ffebee;
color: #b71c1c;
}
.tenue {
color: #607d8b;
font-size: 0.9rem;
}
.oscuro {
background: #263238;
color: #eceff1;
border-color: #455a64;
}
.oscuro h2 {
color: #c5cae9;
}
.oscuro .tenue {
color: #b0bec5;
}
.oscuro td,
.oscuro th {
border-color: #455a64;
}
Desde la sección 5 los ejemplos usan la API EMPRESA. Descarga empresa-api.zip, descomprímelo y arráncalo en otra terminal con node server.mjs. Quedará en http://localhost:3000/api. Crea además este fichero con los tipos que comparten los ejemplos:
// src/api.ts
export const API = 'http://localhost:3000/api';
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
deptId: number;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
}
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
2. Convenciones
Las convenciones de los hooks no son manías de estilo. Algunas las necesita el linter para protegerte.
El prefijo use
Un hook se llama use seguido de una mayúscula: useFetch, useOnlineStatus. Ese prefijo es la señal que usan las herramientas para saber a qué funciones aplicar las reglas de los hooks, que viste en el módulo 04. Mira qué pasa con estas tres funciones:
// Sin el prefijo use: el linter no lo trata como hook
export function cargarNombre() {
const [nombre, setNombre] = useState('');
useEffect(() => {
setNombre('Ana');
}, []);
return nombre;
}
// Con prefijo use, pero sin ningún hook dentro
export function useFormatoEuros(importe: number) {
return `${importe.toFixed(2)} €`;
}
// Llamada condicional
export function useDato(activo: boolean) {
if (activo) {
const [dato] = useState(1);
return dato;
}
return 0;
}
La salida real de npm run lint (oxlint) sobre ese fichero, resumida, es esta:
error react-hooks(rules-of-hooks): React Hook "useState" is called in function
"cargarNombre" that is neither a React function component nor a custom React
Hook function. React component names must start with an uppercase letter.
React Hook names must start with the word "use".
error react-hooks(rules-of-hooks): React Hook "useEffect" is called in function
"cargarNombre" that is neither a React function component nor a custom React
Hook function. [...]
error react-hooks(rules-of-hooks): React Hook "useState" is called conditionally.
React Hooks must be called in the exact same order in every component render.
help: Move the Hook call before the condition, or call it unconditionally and
branch inside the Hook/effect instead.
cargarNombrefalla por el nombre. El mismo código conuseNombresería correcto.useDatofalla por llamar auseStatedentro de unif. Ser un hook no te libra de las reglas: un hook propio se ejecuta dentro del componente y sus llamadas forman parte de la misma secuencia.useFormatoEurosno da error, pero engaña. Quien lo lea creerá que tiene estado o efectos y que no puede llamarlo dentro de un bucle. Como no usa ningún hook, debe ser una función normal:formatearEuros.
⚠️ Importante: Si una función no llama a ningún hook, no la llames
use.... El prefijo es una promesa: “esta función sigue las reglas de los hooks”. Una función corriente se puede usar en cualquier sitio, incluso fuera de React, y eso es una ventaja que no conviene perder.
Qué recibe y qué devuelve
Un hook acepta los parámetros que necesite y devuelve lo que necesite: nada, un valor, un array o un objeto. La elección del retorno afecta a cómo se usa:
| Retorno | Ejemplo | Cuándo conviene | Inconveniente |
|---|---|---|---|
| Un valor | const enLinea = useOnlineStatus() | Hay un único dato | No admite añadir más sin cambiar la firma |
| Array de 2 o 3 elementos | const [tema, setTema] = useLocalStorage(...) | Se parece a useState y lo normal es renombrar | Con más de tres elementos nadie recuerda el orden |
| Objeto | const { estado, datos, error } = useFetch(...) | Varios datos con nombre, y se usa solo una parte | Para usarlo dos veces en un componente hay que renombrar: { datos: lista } |
| Nada | useTituloDocumento('Panel') | El hook solo produce un efecto | Ninguno |
Si devuelves un array en TypeScript, añade as const. Sin él, TypeScript deduce un array de una unión, (string | ((v: string) => void))[], y cada elemento pierde su tipo. Con as const deduce una tupla de solo lectura, con un tipo por posición. El módulo 13 profundiza en ello.
Hooks con nombre de propósito
Nombra el hook por lo que aporta, no por el mecanismo. Un buen hook se puede explicar sin mencionar useEffect:
// ❌ Hooks de ciclo de vida: esconden las dependencias y engañan al linter
useMontar(() => cargarDatos());
useActualizar(() => guardar(valor));
// ✅ Hooks con propósito: dicen qué ocurre, no cuándo
const conexion = useOnlineStatus();
const { datos } = useFetch(url);
useTituloDocumento(`Pedido ${pedidoId}`);
Hooks como useMontar suelen existir para escribir useEffect(fn, []) con otro nombre. Lo malo es que la regla exhaustive-deps deja de comprobar las dependencias, porque ya no ve el efecto, y reaparecen los errores que estudiaste en el módulo 09.
Cuándo extraer y cuándo no
No hace falta un hook para cada useState. Extrae cuando se cumpla alguna de estas condiciones:
- La misma lógica con estado aparece en dos o más sitios.
- Un componente se ha llenado de efectos y referencias que tapan su propósito.
- Quieres probar la lógica sin montar la interfaz.
- Estás sincronizando con algo externo. Encapsularlo te permite cambiar la implementación sin tocar a quien lo usa, como haremos enseguida con
useOnlineStatus.
Si lo que se repite no usa hooks, basta una función. Si lo que se repite es JSX, lo que necesitas es un componente.
3. Un hook comparte lógica, no estado
Este es el malentendido más frecuente. Mira este hook para campos de formulario:
// src/hooks/useCampo.ts
import { useState } from 'react';
import type { ChangeEvent } from 'react';
export function useCampo(inicial: string) {
const [valor, setValor] = useState(inicial);
function alCambiar(evento: ChangeEvent<HTMLInputElement>) {
setValor(evento.target.value);
}
return { value: valor, onChange: alCambiar };
}
Devuelve justo las dos props que necesita un <input>, así que se puede usar con spread:
// src/App.tsx
import { useState } from 'react';
import type { SubmitEvent } from 'react';
import { useCampo } from './hooks/useCampo';
export default function App() {
const nombre = useCampo('Ana');
const oficio = useCampo('DIRECTOR GENERAL');
const [enviado, setEnviado] = useState('');
function alEnviar(evento: SubmitEvent<HTMLFormElement>) {
evento.preventDefault();
setEnviado(`${nombre.value} (${oficio.value})`);
}
return (
<form className="tarjeta" onSubmit={alEnviar}>
<h2>Alta rápida</h2>
<label>
Nombre: <input {...nombre} />
</label>
<label>
Oficio: <input {...oficio} />
</label>
<button>Enviar</button>
{enviado && <p className="aviso ok">Enviado: {enviado}</p>}
</form>
);
}
nombre y oficio llaman al mismo hook y son completamente independientes. Tampoco importa que el hook se use en dos componentes: si un <Formulario /> y otro <Formulario /> llaman a useCampo, cada uno tiene sus propios valores. Hemos comprobado con una prueba que dos usos de un contador en un hook avanzan por separado: al sumar en uno, el otro sigue en 0.
La razón ya la conoces. Cada llamada a useState crea una celda de estado asociada a ese componente y a esa posición. Escribir la llamada dentro de otra función no cambia nada.
Figura 3. Los hooks reutilizan código. Para compartir un dato, este debe vivir en un sitio común.
Si dos componentes deben ver el mismo dato tienes tres caminos:
- Elevar el estado al ancestro común, como en el módulo 04.
- Ponerlo en un Context, como en el módulo 11.
- Guardarlo fuera de React y leerlo con
useSyncExternalStore, que es el tema de la siguiente sección.
💡 Consejo: En
useCampoel tipo del evento esChangeEvent, y en el formulario el del envío esSubmitEvent, ambos importados dereact. En@types/react19.3 el tipoFormEventestá marcado como obsoleto: su comentario dice que no existe en realidad y remite aChangeEvent,InputEventoSubmitEvent. ImportaSubmitEventdesdereactde forma explícita, porque el DOM tiene un tipo global con el mismo nombre.
4. useSyncExternalStore: datos que viven fuera de React
useOnlineStatus funciona, pero tiene un defecto sutil: navigator.onLine es el dato verdadero y el estado de React es una copia. Copiar un dato externo a useState dentro de un efecto crea dos fuentes de verdad, con estas consecuencias:
- Entre el primer render y la ejecución del efecto, el dato puede cambiar y nadie se entera.
- Cada componente mantiene su propia copia y su propia suscripción.
- En el servidor no existe
navigator, así queuseState(navigator.onLine)fallaría al renderizar allí (módulo 23). - Durante un render concurrente, React puede interrumpir el trabajo a medias. Si el dato externo cambia en ese hueco, una parte de la pantalla puede pintarse con el valor viejo y otra con el nuevo. Se llama tearing, “desgarro”, y lo retomaremos en el módulo 15.
useSyncExternalStore existe desde React 18 precisamente para esto. No copia el dato en tu estado: le dice a React cómo leerlo y cómo enterarse de sus cambios, y React se ocupa del resto.
const valor = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
| Argumento | Qué es | Reglas |
|---|---|---|
subscribe(aviso) | Se da de alta en la fuente y devuelve la función de baja | Debe llamar a aviso cuando el dato cambie. Si pasas otra función, React se da de baja y se suscribe otra vez |
getSnapshot() | Devuelve el valor actual, la “instantánea” (snapshot) | Si el dato no ha cambiado, debe devolver lo mismo según Object.is |
getServerSnapshot() | Devuelve el valor para el HTML del servidor y para la hidratación | Opcional en aplicaciones que solo funcionan en el cliente, obligatorio con renderizado en servidor |
Con él, useOnlineStatus queda más corto y más correcto. Los componentes no cambian en absoluto, y esa es la gran ventaja de haber encapsulado la lógica:
// src/hooks/useOnlineStatus.ts
import { useSyncExternalStore } from 'react';
// Fuera del componente: la misma función en todos los renders
function suscribir(aviso: () => void) {
window.addEventListener('online', aviso);
window.addEventListener('offline', aviso);
return () => {
window.removeEventListener('online', aviso);
window.removeEventListener('offline', aviso);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(
suscribir, // cómo enterarse de los cambios
() => navigator.onLine, // cuánto vale ahora en el navegador
() => true, // qué valor usar al renderizar en el servidor
);
}
Figura 4. React lee durante el render, se suscribe tras el commit y vuelve a leer cuando recibe el aviso.
Observa que suscribir no recibe el valor nuevo ni lo pasa: su única misión es avisar de que algo ha pasado. Tras el aviso, React llama otra vez a getSnapshot, compara con la instantánea anterior y, si difiere, vuelve a renderizar el componente de forma síncrona. Esa es la explicación del Sync del nombre: esa actualización no se puede interrumpir, porque si se interrumpiera podría aparecer el tearing. Tiene un coste: si el almacén cambia durante una transición, React renuncia a tratar esa actualización como no bloqueante.
Error típico 1: subscribe cambia en cada render
// ⚠️ Funciona, pero con una suscripción nueva en cada render
function useOnlineStatus() {
return useSyncExternalStore(
(aviso) => {
window.addEventListener('online', aviso);
return () => window.removeEventListener('online', aviso);
},
() => navigator.onLine,
);
}
Esta función flecha es distinta en cada render, así que React se da de baja y se vuelve a suscribir cada vez. Define suscribir fuera del componente. Si necesita datos del hook, envuélvela en useCallback, como harás en useMediaQuery.
Error típico 2: getSnapshot devuelve un objeto nuevo
Este es mucho peor. Fíjate en que esta instantánea es un objeto recién creado en cada llamada:
// ❌ MAL: cada llamada devuelve un objeto distinto
const valor = useSyncExternalStore(suscribir, () => ({ ancho: window.innerWidth }));
React llama a getSnapshot, ve que el resultado no es igual al anterior según Object.is, deduce que el dato ha cambiado, vuelve a renderizar y vuelve a llamar. Es un bucle infinito. Lo hemos ejecutado y estos son los dos mensajes reales:
The result of getSnapshot should be cached to avoid an infinite loop
Maximum update depth exceeded. This can happen when a component repeatedly calls
setState inside componentWillUpdate or componentDidUpdate. [...]
Hay tres formas de arreglarlo:
- Devolver un valor primitivo: número, cadena o booleano.
() => window.innerWidthes perfecto. - Devolver un objeto que el almacén ya tenga guardado y que solo sustituya al cambiar. Así lo hace
crearAlmacenmás abajo. - Guardar en una variable la última instantánea y reutilizarla si los datos coinciden. Lo harás en el ejercicio 3.
Error típico 3: olvidar getServerSnapshot
Si tu componente se renderiza en servidor y no pasas el tercer argumento, React lanza este error, que hemos reproducido con renderToString:
Missing getServerSnapshot, which is required for server-rendered content.
Will revert to client rendering.
En el servidor no hay navigator, ni localStorage, ni matchMedia. El tercer argumento da el valor que se pondrá en el HTML. Durante la hidratación React usa también ese valor, para que coincida con el HTML, y justo después renderiza con el valor real.
Tu propio almacén
useSyncExternalStore no sirve solo para APIs del navegador. Con veinte líneas puedes crear un almacén global, sin Context y sin proveedores. Esta es la idea en la que se apoyan librerías como Zustand, que verás en el módulo 20:
// src/almacen.ts
export interface Almacen<T> {
leer: () => T;
escribir: (cambio: (actual: T) => T) => void;
suscribir: (aviso: () => void) => () => void;
}
export function crearAlmacen<T>(inicial: T): Almacen<T> {
let estado = inicial;
const oyentes = new Set<() => void>();
return {
leer: () => estado,
escribir(cambio) {
estado = cambio(estado);
oyentes.forEach((oyente) => oyente());
},
suscribir(aviso) {
oyentes.add(aviso);
return () => {
oyentes.delete(aviso);
};
},
};
}
Y el hook que lo conecta a React. El selector permite que cada componente lea solo la parte que le interesa:
// src/hooks/useAlmacen.ts
import { useSyncExternalStore } from 'react';
import type { Almacen } from '../almacen';
export function useAlmacen<T, S>(almacen: Almacen<T>, selector: (estado: T) => S): S {
const leerSeleccion = () => selector(almacen.leer());
return useSyncExternalStore(almacen.suscribir, leerSeleccion, leerSeleccion);
}
Ahora un ejemplo en el que el dato sí se comparte, a diferencia de lo que ocurría en la sección 3:
// src/App.tsx
import { crearAlmacen } from './almacen';
import { useAlmacen } from './hooks/useAlmacen';
interface Linea {
producto: string;
precio: number;
unidades: number;
}
// Un solo almacén para toda la aplicación, fuera de React
const cesta = crearAlmacen<Linea[]>([]);
function anadir(producto: string, precio: number) {
cesta.escribir((lineas) => {
const existente = lineas.find((linea) => linea.producto === producto);
if (existente === undefined) {
return [...lineas, { producto, precio, unidades: 1 }];
}
return lineas.map((linea) =>
linea === existente ? { ...linea, unidades: linea.unidades + 1 } : linea,
);
});
}
function Cabecera() {
// Devuelve un número: solo se renderiza si el número cambia
const unidades = useAlmacen(cesta, (lineas) =>
lineas.reduce((suma, linea) => suma + linea.unidades, 0),
);
return <p className="aviso ok">Cesta: {unidades} unidades</p>;
}
function Total() {
const total = useAlmacen(cesta, (lineas) =>
lineas.reduce((suma, linea) => suma + linea.unidades * linea.precio, 0),
);
return <p>Total: {total.toFixed(2)} €</p>;
}
export default function App() {
return (
<section className="tarjeta">
<h2>Pedido</h2>
<Cabecera />
<div className="fila">
<button onClick={() => anadir('Teclado', 39.9)}>Añadir teclado</button>
<button onClick={() => anadir('Monitor', 189)}>Añadir monitor</button>
</div>
<Total />
</section>
);
}
Tras pulsar dos veces en el teclado y una en el monitor, la cabecera muestra Cesta: 3 unidades y el total 268.80 €. Ningún componente recibe props ni está dentro de un proveedor, y anadir es una función normal, que podría llamarse incluso desde fuera de React.
Hay una diferencia importante respecto al Context del módulo 11. Con Context, todo lector se renderiza cuando cambia el valor. Aquí se compara lo que devuelve el selector, así que una prueba con { a, b } nos ha dado lo esperado: un lector de a tiene 1 render tras cambiar b y 2 tras cambiar a.
⚠️ Importante: El selector debe devolver un primitivo o una referencia ya existente en el almacén.
useAlmacen(cesta, (l) => l.filter(...))crea un array nuevo en cada lectura y provoca el error típico 2. Si necesitas datos derivados complejos, usa un selector primitivo o lee el array y deriva en el componente.
| Fuente del dato | Herramienta adecuada |
|---|---|
| Estado propio de un componente | useState, useReducer |
| Estado compartido por un subárbol | Context |
API del navegador con eventos (matchMedia, online, storage) | useSyncExternalStore |
| Almacén propio o de una librería | useSyncExternalStore |
Conexión con ciclo de vida (WebSocket, mapa, fetch) | useEffect |
5. useFetch: pedir datos a la API
Pedir datos es el ejemplo clásico de hook personalizado. El módulo 09 ya explicó las condiciones de carrera y AbortController, así que aquí nos centramos en empaquetarlo bien. Queremos que el hook cumpla esto:
- Vuelva a pedir cuando cambie la URL, cancelando la petición anterior.
- Acepte
nullcomo URL para decir “todavía no pidas nada”. - Distinga cuatro situaciones: inactivo, cargando, correcto y error.
- Muestre el
mensajede los errores de la API EMPRESA, como{ "status": 404, "mensaje": "..." }. - Permita recargar a mano.
- Conserve los datos anteriores mientras llegan los nuevos, para que la tabla no parpadee.
// src/hooks/useFetch.ts
import { useCallback, useEffect, useState } from 'react';
export type EstadoPeticion = 'inactivo' | 'cargando' | 'ok' | 'error';
interface Resultado<T> {
clave: string | null;
datos: T | null;
error: string | null;
}
async function pedirJson<T>(url: string, signal: AbortSignal): Promise<T> {
const respuesta = await fetch(url, { signal });
if (!respuesta.ok) {
const cuerpo: { mensaje?: string } | null = await respuesta.json().catch(() => null);
throw new Error(cuerpo?.mensaje ?? `Error HTTP ${respuesta.status}`);
}
return (await respuesta.json()) as T;
}
export function useFetch<T>(url: string | null) {
const [intento, setIntento] = useState(0);
const [resultado, setResultado] = useState<Resultado<T> | null>(null);
// La clave identifica "esta URL en este intento"
const clave = url === null ? null : `${intento}|${url}`;
useEffect(() => {
if (url === null) return;
const controlador = new AbortController();
const { signal } = controlador;
pedirJson<T>(url, signal).then(
(datos) => {
if (!signal.aborted) setResultado({ clave, datos, error: null });
},
(causa: unknown) => {
if (signal.aborted) return;
const error = causa instanceof Error ? causa.message : String(causa);
setResultado({ clave, datos: null, error });
},
);
return () => controlador.abort();
}, [url, clave]);
const recargar = useCallback(() => setIntento((n) => n + 1), []);
// El estado se deriva: no hay ningún setState que lo ponga en "cargando"
let estado: EstadoPeticion;
if (clave === null) estado = 'inactivo';
else if (resultado?.clave !== clave) estado = 'cargando';
else if (resultado.error !== null) estado = 'error';
else estado = 'ok';
return {
estado,
datos: estado === 'inactivo' ? null : (resultado?.datos ?? null),
error: estado === 'error' ? (resultado?.error ?? null) : null,
recargar,
};
}
Merece la pena leerlo con calma, porque tiene varias decisiones de diseño:
fetch no rechaza con un 404. La promesa solo se rechaza si falla la red o la petición se aborta. Un 404 o un 500 llegan como respuesta normal, con ok a false. Por eso pedirJson comprueba respuesta.ok y lanza su propio error. El .catch(() => null) cubre el caso de que el cuerpo del error no sea JSON.
cargando no se guarda: se deriva. La versión habitual hace setCargando(true) al principio del efecto. Eso provoca un render de más y además lo señala la regla set-state-in-effect que trae oxlint. Aquí aplicamos lo que viste en el módulo 04 sobre estado derivado. El estado guarda solo el resultado y a qué clave pertenece. Si la clave del resultado no coincide con la actual, es que la respuesta que queremos aún no ha llegado.
clave incluye un contador de intentos. Para recargar la misma URL hace falta que algo cambie. recargar incrementa intento, cambia la clave y el efecto se vuelve a ejecutar.
La comprobación de signal.aborted. La limpieza aborta la petición anterior. Si esta se rechaza por ello, no hay nada que mostrar. Y si ya se había resuelto, tampoco queremos que un resultado antiguo pise uno nuevo.
recargar usa useCallback. Así su identidad es estable y se puede poner en las dependencias de un efecto. Lo vemos en la sección 12.
Observa el uso desde dos componentes de una misma pantalla. El primero carga los departamentos y la lista de empleados con dos llamadas al hook, y el segundo usa null mientras no haya nada escrito:
// src/App.tsx
import { useState } from 'react';
import { API } from './api';
import type { Departamento, Empleado, Pagina } from './api';
import { useFetch } from './hooks/useFetch';
function ListaDepartamento() {
const [deptId, setDeptId] = useState(20);
const departamentos = useFetch<Pagina<Departamento>>(`${API}/departamentos`);
const { estado, datos, error, recargar } = useFetch<Pagina<Empleado>>(
`${API}/empleados?deptId=${deptId}&tamano=5&orden=apellidos`,
);
return (
<section className="tarjeta">
<h2>Empleados por departamento</h2>
<div className="fila">
<select value={deptId} onChange={(e) => setDeptId(Number(e.target.value))}>
{departamentos.datos?.contenido.map((d) => (
<option key={d.deptId} value={d.deptId}>{d.nombre}</option>
))}
</select>
<button onClick={recargar}>Recargar</button>
<span className="tenue">Estado: {estado}</span>
</div>
{error && <p className="aviso error">{error}</p>}
{datos && (
<table style={{ opacity: estado === 'cargando' ? 0.5 : 1 }}>
<tbody>
{datos.contenido.map((empleado) => (
<tr key={empleado.empId}>
<td>{empleado.nombre} {empleado.apellidos}</td>
<td>{empleado.oficio}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
function FichaEmpleado() {
const [id, setId] = useState('');
const { estado, datos, error } = useFetch<Empleado>(id === '' ? null : `${API}/empleados/${id}`);
return (
<section className="tarjeta">
<h2>Buscar por identificador</h2>
<label>
Identificador: <input value={id} onChange={(e) => setId(e.target.value)} placeholder="100" />
</label>
{estado === 'inactivo' && <p className="tenue">Escribe un identificador.</p>}
{estado === 'cargando' && <p className="tenue">Cargando...</p>}
{estado === 'error' && <p className="aviso error">{error}</p>}
{estado === 'ok' && datos && <p className="aviso ok">{datos.nombre} {datos.apellidos}: {datos.salario} €</p>}
</section>
);
}
export default function App() {
return (
<>
<ListaDepartamento />
<FichaEmpleado />
</>
);
}

Figura 5. Arriba, la lista espera la respuesta conservando los datos anteriores. Abajo, el mensaje que devuelve la API con un 404.
Comprobaciones hechas en el navegador real contra la API:
| Acción | Resultado |
|---|---|
Escribir 100 en el identificador | Ana García López: 6500 € |
Escribir 999 | Error Empleado 999 no encontrado |
| Cambiar al departamento 30 con la respuesta retrasada 800 ms | Estado: cargando, con la tabla anterior atenuada |
| Cambiar al 40 antes de que llegue el 30 | Termina en ok con los empleados del 40 |
El último caso es la condición de carrera del módulo 09, resuelta dentro del hook. Quien lo usa ni siquiera tiene que saber que existe.
En desarrollo, <StrictMode> monta, desmonta y vuelve a montar cada componente. Por eso en la pestaña Network verás al arrancar una petición cancelada seguida de otra correcta. Es lo esperado: la limpieza funciona.
💡 Consejo:
useFetches un buen ejercicio, pero en una aplicación real se queda corto: no tiene caché, dos componentes con la misma URL hacen dos peticiones y no reintenta. Para eso existe TanStack Query, que verás en el módulo 19. Usa este hook para entender el problema y para aplicaciones pequeñas.
6. useDebounce: esperar a que el usuario termine
Si cada pulsación en un buscador lanza una petición, escribir garcia genera seis. El rebote (debounce) consiste en esperar a que un valor lleve un rato sin cambiar antes de hacerle caso.
// src/hooks/useDebounce.ts
import { useEffect, useState } from 'react';
export function useDebounce<T>(valor: T, retardoMs = 300): T {
const [retardado, setRetardado] = useState(valor);
useEffect(() => {
const id = setTimeout(() => setRetardado(valor), retardoMs);
return () => clearTimeout(id);
}, [valor, retardoMs]);
return retardado;
}
Son diez líneas, pero muestran bien cómo trabaja la limpieza de un efecto. Cada vez que valor cambia, React limpia el efecto anterior, lo que cancela el temporizador pendiente, y lanza uno nuevo. Mientras sigas escribiendo a menos de 300 ms entre teclas, ningún temporizador llega a terminar. Cuando paras, el último vence y actualiza el estado.
Lo interesante es que useDebounce y useFetch no se conocen. Basta con pasar el resultado de uno como entrada del otro:
// src/App.tsx
import { useState } from 'react';
import { API } from './api';
import type { Empleado, Pagina } from './api';
import { useDebounce } from './hooks/useDebounce';
import { useFetch } from './hooks/useFetch';
export default function App() {
const [texto, setTexto] = useState('');
const buscado = useDebounce(texto.trim(), 300);
const url = buscado === '' ? null : `${API}/empleados?q=${encodeURIComponent(buscado)}&tamano=6`;
const { estado, datos, error } = useFetch<Pagina<Empleado>>(url);
return (
<section className="tarjeta">
<h2>Buscador de empleados</h2>
<label>
Buscar: <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="nombre o apellido" />
</label>
<p className="tenue">
Escrito: "{texto}". Buscado: "{buscado}". Estado: {estado}.
</p>
{error && <p className="aviso error">{error}</p>}
{datos && (
<ul>
{datos.contenido.map((empleado) => (
<li key={empleado.empId}>
{empleado.nombre} {empleado.apellidos}
</li>
))}
</ul>
)}
</section>
);
}
El input usa texto, que cambia en cada tecla y mantiene la escritura fluida. La petición usa buscado, que cambia solo cuando el usuario hace una pausa. Hemos medido las peticiones reales escribiendo garcia con 80 ms entre teclas:
Sin useDebounce: 6 peticiones q=g q=ga q=gar q=garc q=garci q=garcia
Con useDebounce: 1 petición q=garcia

Figura 6. El párrafo gris muestra el texto escrito, el texto buscado y el estado de la petición.
No confundas tres técnicas parecidas:
| Técnica | Qué hace | Uso típico |
|---|---|---|
| Debounce | Espera a que pare de cambiar | Buscadores, autoguardado |
| Throttle | Permite un cambio como máximo cada cierto tiempo | Scroll, redimensionar, arrastrar |
useDeferredValue | Pospone un render costoso, sin un tiempo fijo y sin evitar el cálculo | Listas lentas en cliente (módulo 15) |
useDeferredValue no sustituye a debounce si lo que quieres es lanzar menos peticiones, porque no espera una cantidad de tiempo determinada.
7. useLocalStorage: estado que sobrevive a la recarga
localStorage guarda cadenas en el navegador y no caduca al cerrar la pestaña. Viene bien para preferencias: tema, tamaño de página, última búsqueda.
Primera versión: con useState
Es la que escribe casi todo el mundo:
// src/hooks/useLocalStorage.ts
import { useEffect, useState } from 'react';
export function useLocalStorage<T>(clave: string, inicial: T) {
const [valor, setValor] = useState<T>(() => {
const texto = localStorage.getItem(clave);
return texto === null ? inicial : (JSON.parse(texto) as T);
});
useEffect(() => {
localStorage.setItem(clave, JSON.stringify(valor));
}, [clave, valor]);
return [valor, setValor] as const;
}
Parece suficiente, y si solo la usa un componente, lo es. Pero copia el dato externo en un useState, y ya sabes por la sección 4 lo que eso implica:
- Dos componentes con la misma clave no se enteran. Lo hemos comprobado con una prueba: tras escribir
2en uno, el otro sigue en1. - Otra pestaña tampoco se entera.
JSON.parsepuede lanzar si alguien guardó algo que no es JSON, y el componente se rompería al montarse.- Rompe el renderizado en servidor, porque allí no existe
localStorage.
Versión buena: con useSyncExternalStore
localStorage es un almacén externo, así que la herramienta correcta ya la conoces. Queda un detalle del navegador por resolver: el evento storage se dispara en las demás pestañas, nunca en la que hizo el cambio. Para cubrir la pestaña actual emitimos un evento propio.
// src/hooks/useLocalStorage.ts
import { useCallback, useMemo, useSyncExternalStore } from 'react';
// El evento "storage" solo llega a las OTRAS pestañas.
// Para esta pestaña emitimos un evento propio.
const EVENTO_LOCAL = 'cambio-localstorage';
function suscribir(aviso: () => void) {
window.addEventListener('storage', aviso);
window.addEventListener(EVENTO_LOCAL, aviso);
return () => {
window.removeEventListener('storage', aviso);
window.removeEventListener(EVENTO_LOCAL, aviso);
};
}
function interpretar<T>(texto: string | null, inicial: T): T {
if (texto === null) return inicial;
try {
return JSON.parse(texto) as T;
} catch {
return inicial; // alguien guardó algo que no es JSON
}
}
type Cambio<T> = T | ((anterior: T) => T);
export function useLocalStorage<T>(clave: string, inicial: T) {
// La instantánea es la CADENA guardada: dos cadenas iguales son el mismo valor
const texto = useSyncExternalStore(
suscribir,
() => localStorage.getItem(clave),
() => null,
);
const valor = useMemo(() => interpretar(texto, inicial), [texto, inicial]);
const guardar = useCallback(
(cambio: Cambio<T>) => {
const anterior = interpretar(localStorage.getItem(clave), inicial);
const nuevo = typeof cambio === 'function' ? (cambio as (a: T) => T)(anterior) : cambio;
localStorage.setItem(clave, JSON.stringify(nuevo));
window.dispatchEvent(new Event(EVENTO_LOCAL));
},
[clave, inicial],
);
const borrar = useCallback(() => {
localStorage.removeItem(clave);
window.dispatchEvent(new Event(EVENTO_LOCAL));
}, [clave]);
return [valor, guardar, borrar] as const;
}
Fíjate en estos puntos:
- La instantánea es la cadena, no el objeto. Las cadenas se comparan por contenido, así que
getSnapshotcumple la regla sin caché alguna.JSON.parsese hace enuseMemo, solo cuando la cadena cambia. Si hiciéramos elparsedentro degetSnapshottendríamos el error típico 2 con cualquier objeto. guardarleelocalStorage, no elvalordel render. Así la función actualizadora siempre parte del dato real, incluso con dos llamadas seguidas.- El cast de
cambio. TypeScript no puede saber queTno es una función. Es la misma ambigüedad que tieneuseState, y por eso este hook no sirve para guardar funciones. De todos modos, JSON no las admite. inicialforma parte de las dependencias. Pásale una constante o un valor primitivo. Si escribesuseLocalStorage('filtros', {})directamente, cada render creará un objeto nuevo yvaloryguardarcambiarán de identidad sin motivo cuando no haya nada guardado. Declara el objeto fuera del componente.
// src/App.tsx
import { useLocalStorage } from './hooks/useLocalStorage';
function SelectorTamano() {
const [tamano, setTamano] = useLocalStorage('tamano-pagina', 10);
return (
<label>
Filas por página:{' '}
<select value={tamano} onChange={(e) => setTamano(Number(e.target.value))}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
</select>
</label>
);
}
function Resumen() {
// Otro componente con la misma clave: lee el mismo dato
const [tamano] = useLocalStorage('tamano-pagina', 10);
const [visitas, setVisitas, borrarVisitas] = useLocalStorage('visitas', 0);
return (
<>
<p className="aviso ok">
Se mostrarán {tamano} filas. Contador compartido: {visitas}.
</p>
<div className="fila">
<button onClick={() => setVisitas((v) => v + 1)}>Sumar uno</button>
<button onClick={borrarVisitas}>Borrar</button>
</div>
</>
);
}
export default function App() {
return (
<section className="tarjeta">
<h2>Preferencias</h2>
<SelectorTamano />
<Resumen />
</section>
);
}
Hemos abierto dos pestañas del mismo origen en Chromium y cambiado los valores solo en la primera. Esto muestra cada pestaña:
Pestaña 1: Se mostrarán 25 filas. Contador compartido: 2.
Pestaña 2: Se mostrarán 25 filas. Contador compartido: 2.
Tras pulsar Borrar en la pestaña 2, la 1 muestra: Contador compartido: 0.
Tras recargar la pestaña 1: Se mostrarán 25 filas. Contador compartido: 0.

Figura 7. Captura de la segunda pestaña. Nadie la ha tocado: el evento storage la ha actualizado.
⚠️ Importante:
localStorageno es un sitio seguro. Cualquier script de la página puede leerlo, así que no guardes ahí contraseñas ni datos personales. Además,setItempuede lanzarQuotaExceededErrorsi se llena el espacio. Para datos grandes o críticos, envuelve la escritura entry/catchy decide qué hacer. La seguridad en el cliente se trata en el módulo 25.
8. useMediaQuery: las consultas de medios desde JavaScript
window.matchMedia evalúa una consulta CSS y avisa cuando cambia el resultado. Es otro almacén externo, con una particularidad: la consulta es un parámetro del hook, así que suscribir depende de ella y debe estar dentro.
// src/hooks/useMediaQuery.ts
import { useCallback, useSyncExternalStore } from 'react';
export function useMediaQuery(consulta: string) {
const suscribir = useCallback(
(aviso: () => void) => {
const lista = window.matchMedia(consulta);
lista.addEventListener('change', aviso);
return () => lista.removeEventListener('change', aviso);
},
[consulta], // cambia de consulta: hay que suscribirse de nuevo
);
return useSyncExternalStore(
suscribir,
() => window.matchMedia(consulta).matches,
() => false,
);
}
Aquí useCallback no es una optimización: es lo que evita volver a suscribirse en cada render. getSnapshot devuelve un booleano, de modo que no hace falta caché.
El valor false del servidor significa que, con renderizado en servidor, el primer pintado se hace siempre con la variante “no coincide”. Tras hidratar, React renderiza con el valor real. Si eso produce un salto visible, considera resolverlo con CSS.
// src/App.tsx
import { API } from './api';
import type { Empleado, Pagina } from './api';
import { useFetch } from './hooks/useFetch';
import { useMediaQuery } from './hooks/useMediaQuery';
function Tabla({ empleados }: { empleados: Empleado[] }) {
return (
<table>
<thead>
<tr>
<th>Empleado</th>
<th>Oficio</th>
<th>Salario</th>
</tr>
</thead>
<tbody>
{empleados.map((e) => (
<tr key={e.empId}>
<td>{e.nombre} {e.apellidos}</td>
<td>{e.oficio}</td>
<td>{e.salario} €</td>
</tr>
))}
</tbody>
</table>
);
}
function Fichas({ empleados }: { empleados: Empleado[] }) {
return (
<div>
{empleados.map((e) => (
<p key={e.empId}>
<strong>{e.nombre} {e.apellidos}</strong>
<br />
<span className="tenue">{e.oficio}</span>
</p>
))}
</div>
);
}
export default function App() {
const esEstrecha = useMediaQuery('(max-width: 600px)');
const prefiereOscuro = useMediaQuery('(prefers-color-scheme: dark)');
const { datos } = useFetch<Pagina<Empleado>>(`${API}/empleados?tamano=4&orden=-salario`);
const empleados = datos?.contenido ?? [];
return (
<section className={prefiereOscuro ? 'tarjeta oscuro' : 'tarjeta'}>
<h2>Salarios más altos</h2>
<p className="tenue">
Vista {esEstrecha ? 'de fichas' : 'de tabla'}, tema {prefiereOscuro ? 'oscuro' : 'claro'}
</p>
{esEstrecha ? <Fichas empleados={empleados} /> : <Tabla empleados={empleados} />}
</section>
);
}

Figura 8. Ventana de 760 píxeles con tema claro: vista de tabla.

Figura 9. La misma página, sin recargar, tras reducir la ventana a 420 píxeles y cambiar la preferencia del sistema a oscuro.
💡 Consejo: Antes de usar este hook, pregúntate si te basta CSS. Para cambiar columnas, tamaños o colores, una
@mediaen la hoja de estilos es más sencilla y no da saltos. El hook tiene sentido cuando cambia el árbol que se renderiza, como aquí, donde no se montan los mismos componentes, o cuando quieres evitar montar algo pesado en móvil.
9. Composición de hooks
Un hook puede llamar a otros hooks, igual que una función llama a otras. Esto permite una organización en capas: hooks genéricos abajo y hooks de negocio arriba, que hablan el idioma de tu aplicación.
// src/hooks/useBusquedaEmpleados.ts
import { API } from '../api';
import type { Empleado, Pagina } from '../api';
import { useDebounce } from './useDebounce';
import { useFetch } from './useFetch';
import { useLocalStorage } from './useLocalStorage';
import { useOnlineStatus } from './useOnlineStatus';
const MINIMO_LETRAS = 2;
export function useBusquedaEmpleados() {
const [texto, setTexto, olvidar] = useLocalStorage('busqueda-empleados', '');
const buscado = useDebounce(texto.trim(), 300);
const enLinea = useOnlineStatus();
const url =
enLinea && buscado.length >= MINIMO_LETRAS
? `${API}/empleados?q=${encodeURIComponent(buscado)}&tamano=6&orden=apellidos`
: null;
const { estado, datos, error, recargar } = useFetch<Pagina<Empleado>>(url);
return {
texto,
setTexto,
olvidar,
enLinea,
estado,
error,
recargar,
escribiendo: texto.trim() !== buscado,
empleados: datos?.contenido ?? [],
total: datos?.total ?? 0,
};
}
El componente ya no sabe nada de URLs, temporizadores ni localStorage:
// src/App.tsx
import { useBusquedaEmpleados } from './hooks/useBusquedaEmpleados';
export default function App() {
const busqueda = useBusquedaEmpleados();
return (
<section className="tarjeta">
<h2>Directorio de EMPRESA</h2>
{!busqueda.enLinea && <p className="aviso error">Sin conexión: la búsqueda está en pausa.</p>}
<div className="fila">
<input
aria-label="Buscar empleado"
value={busqueda.texto}
onChange={(e) => busqueda.setTexto(e.target.value)}
placeholder="Al menos dos letras"
/>
<button onClick={busqueda.recargar}>Actualizar</button>
<button onClick={busqueda.olvidar}>Limpiar</button>
</div>
<p className="tenue">
{busqueda.escribiendo || busqueda.estado === 'cargando'
? 'Buscando...'
: `Resultados: ${busqueda.total}`}
</p>
{busqueda.error && <p className="aviso error">{busqueda.error}</p>}
<table>
<tbody>
{busqueda.empleados.map((e) => (
<tr key={e.empId}>
<td>{e.apellidos}, {e.nombre}</td>
<td>{e.oficio}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}

Figura 10. El directorio recuerda la búsqueda, espera a que dejes de escribir y se pausa sin conexión.
Figura 11. Cada capa solo conoce a la inmediatamente inferior.
Cuatro hooks y una sola decisión de negocio. El resultado se comprobó en el navegador: al escribir mar se lanza una sola petición, con 3 resultados, al recargar la página la búsqueda sigue ahí con sus resultados, y al cortar la red aparece el aviso y la lista se vacía, sin ninguna petición fallida.
Observa cómo se comunican los hooks: el valor que devuelve uno se pasa como argumento al siguiente, render a render. No hay eventos ni suscripciones entre ellos. El dato fluye como en cualquier función. Cuando cambia buscado, cambia url, y useFetch reacciona porque su efecto depende de ella.
Hooks que envuelven un Context
Otra composición muy común es poner delante de un Context un hook con nombre propio. En el módulo 11 creaste Contextos con su proveedor. Este patrón añade la comprobación del valor por defecto:
// src/hooks/useSesion.ts
import { createContext, useContext } from 'react';
export interface Sesion {
nombre: string;
rol: 'ADMIN' | 'USER';
}
export const ContextoSesion = createContext<Sesion | null>(null);
export function useSesion() {
const sesion = useContext(ContextoSesion);
if (sesion === null) {
throw new Error('useSesion debe usarse dentro de un proveedor de sesión');
}
return sesion; // aquí el tipo ya es Sesion, sin null
}
Tiene dos ventajas. Si alguien lo usa fuera del proveedor, recibe un mensaje claro en vez de un null extraño mucho más adelante. Y en TypeScript, el tipo devuelto ya no incluye null, de manera que no tendrás que comprobarlo en cada componente.
10. Hooks que reciben funciones
Muchos hooks reciben una función: un intervalo que ejecuta algo, una tecla que dispara una acción. Aparece entonces un problema nuevo. Intentemos un useIntervalo ingenuo:
// ❌ Versión 1: se queda con la función del primer render
function useIntervalo(alVencer: () => void, ms: number) {
useEffect(() => {
const id = setInterval(alVencer, ms);
return () => clearInterval(id);
}, [ms]); // el linter avisa: falta alVencer
}
El efecto solo se ejecuta al montar, de modo que se queda con la función de ese render, que a su vez ve las variables de ese render. Es un cierre obsoleto (stale closure). Si añades alVencer a las dependencias, el linter se calla, pero como quien llama pasa una función nueva en cada render, el intervalo se destruye y se crea continuamente. Si el componente se renderiza más a menudo que el periodo, no vence nunca.
Necesitamos que el efecto no dependa de la función y que a la vez llame siempre a la última versión.
Con useEffectEvent 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: guarda la función en una ref y actualízala en
useLayoutEffect. Es la versión compatible que aparece al final de este apartado y se comporta igual.
Esta es exactamente la situación para la que nació useEffectEvent, que se presentó en el módulo 09:
// src/hooks/useIntervalo.ts
import { useEffect, useEffectEvent } from 'react';
export function useIntervalo(alVencer: () => void, ms: number | null) {
const vencer = useEffectEvent(alVencer);
useEffect(() => {
if (ms === null) return; // null significa "en pausa"
const id = setInterval(() => vencer(), ms);
return () => clearInterval(id);
}, [ms]);
}
vencer tiene dos propiedades: siempre ejecuta la última alVencer y no cuenta como dependencia del efecto. Solo se puede llamar desde efectos, no durante el render. Con ms a null se pausa, porque el efecto sale sin crear el intervalo.
Lo hemos comprobado con temporizadores falsos y las dos versiones dan lo mismo: con una prop que pasa de 1 a 2, la función ve [1, 2], y setInterval se llama una sola vez.
⚠️ Importante: En 19.2.9,
useEffectEventdentro de componentes envueltos enmemooforwardReflee valores antiguos. Está corregido en 19.3. Si te quedas en 19.2 y usas un hook como este en esos componentes, prefiere la versión compatible.
Versión compatible con React 18 y 19, para sustituir a la anterior si tu versión no tiene useEffectEvent:
// src/hooks/useIntervalo.ts
import { useEffect, useLayoutEffect, useRef } from 'react';
export function useIntervalo(alVencer: () => void, ms: number | null) {
const ultimo = useRef(alVencer);
useLayoutEffect(() => {
ultimo.current = alVencer; // siempre apunta a la función del último render
});
useEffect(() => {
if (ms === null) return;
const id = setInterval(() => ultimo.current(), ms);
return () => clearInterval(id);
}, [ms]);
}
No se escribe ultimo.current durante el render, porque el render debe ser puro (módulo 10). Se hace en useLayoutEffect, antes de que se ejecute cualquier efecto normal.
11. Hooks que devuelven una ref 🆕 19.0
🆕 Desde React 19.0. Los callback refs pueden devolver una función de limpieza. En React 18 ese valor se ignora y el callback se llama con
nullal desmontar. Si usas una versión anterior: guarda elResizeObserveren unauseRefy desconéctalo en la ramanodo === null.
Algunos hooks necesitan acceder a un nodo del DOM. En vez de pedir a quien llama una ref, pueden devolver una. Un callback ref es mejor que useRef más un efecto, porque se entera de que el nodo ha aparecido aunque se monte más tarde, por ejemplo tras un &&:
// src/hooks/useTamanoElemento.ts
import { useCallback, useState } from 'react';
export function useTamanoElemento<E extends HTMLElement>() {
const [tamano, setTamano] = useState({ ancho: 0, alto: 0 });
const ref = useCallback((nodo: E | null) => {
if (nodo === null) return;
const observador = new ResizeObserver(([entrada]) => {
const { width, height } = entrada.contentRect;
setTamano({ ancho: Math.round(width), alto: Math.round(height) });
});
observador.observe(nodo);
return () => observador.disconnect();
}, []);
return [ref, tamano] as const;
}
// src/App.tsx
import { useState } from 'react';
import { useTamanoElemento } from './hooks/useTamanoElemento';
export default function App() {
const [ancho, setAncho] = useState(300);
const [ref, tamano] = useTamanoElemento<HTMLDivElement>();
return (
<section className="tarjeta">
<h2>Tamaño de un elemento</h2>
<button onClick={() => setAncho(ancho === 300 ? 420 : 300)}>Cambiar ancho</button>
<div ref={ref} style={{ width: ancho, height: 40, background: '#e8eaf6', marginTop: 8 }} />
<p>
{tamano.ancho} x {tamano.alto} px
</p>
</section>
);
}
El ejemplo muestra 300 x 40 px y, tras pulsar el botón, 420 x 40 px. useCallback es necesario: sin él, el ref sería una función nueva en cada render, así que React limpiaría y volvería a crear el observador en cada uno. Como setTamano provoca un render, podrías acabar en un ciclo. La limpieza de refs se explicó en el módulo 10.
12. Estabilidad de lo que devuelves
Un hook se ejecuta en cada render, así que todo lo que crea dentro es nuevo cada vez. Importa cuando quien lo usa pone el resultado en unas dependencias:
const { recargar } = useFetch(url);
useEffect(() => {
const id = setInterval(recargar, 30000);
return () => clearInterval(id);
}, [recargar]); // si recargar cambia en cada render, el intervalo no sobrevive
Por eso recargar usa useCallback en useFetch, y guardar en useLocalStorage. La regla práctica es esta:
- Las funciones de un hook reutilizable, envuélvelas en
useCallback. No sabes dónde se usarán. - El objeto contenedor que devuelves puede ser nuevo. Quien lo usa lo desestructura y depende de cada propiedad, no del objeto.
- Si devuelves un dato calculado que es un array u objeto, usa
useMemosi es costoso o si puede acabar en dependencias.
React Compiler hace esta memorización automáticamente, y lo verás en el módulo 14. Mientras tanto, un hook de librería debería ser estable por sí mismo, porque no sabe si la aplicación usa el compilador.
13. useDebugValue: etiquetas en las DevTools
En la pestaña Components de React DevTools, los hooks propios aparecen por su nombre, sin el use, con los hooks que tienen dentro. useDebugValue les añade una etiqueta legible:
import { useDebugValue, useSyncExternalStore } from 'react';
export function useOnlineStatus() {
const enLinea = useSyncExternalStore(suscribir, () => navigator.onLine, () => true);
useDebugValue(enLinea ? 'En línea' : 'Sin conexión');
return enLinea;
}
El segundo argumento, opcional, es un formateador que solo se ejecuta si las DevTools están inspeccionando el hook. Sirve para evitar trabajo innecesario:
useDebugValue(fecha, (valor) => valor.toLocaleDateString('es-ES'));
No hace falta ponerlo en todos los hooks. Resérvalo para los de librerías compartidas o para los que tienen un estado difícil de leer.
14. Probar hooks personalizados
Una de las ventajas de extraer la lógica es que se puede probar sin interfaz. El módulo 22 trata las pruebas a fondo. Aquí verás solo lo necesario para hooks.
Un hook no se puede llamar desde una prueba porque, recuerda, necesita ejecutarse dentro de un componente. renderHook de Testing Library monta un componente mínimo que lo llama:
| Herramienta | Para qué sirve |
|---|---|
renderHook(fn, opciones) | Monta un componente de prueba que ejecuta fn en cada render |
result.current | Último valor que devolvió el hook. Léelo siempre así, nunca lo guardes antes de actuar |
rerender(props) | Vuelve a renderizar con otras initialProps, como si el padre cambiara las props |
unmount() | Desmonta, para comprobar la limpieza |
act(fn) | Ejecuta código que cambia el estado y espera a que React aplique los cambios |
waitFor(fn) | Reintenta hasta que la aserción pase, para trabajo asíncrono real |
vi.useFakeTimers() | Sustituye setTimeout por un reloj que controlas tú |
vi.stubGlobal(nombre, valor) | Sustituye fetch o matchMedia por una versión falsa |
wrapper | Opción de renderHook que envuelve el hook en un proveedor |
Instala lo necesario y crea la configuración en la raíz del proyecto:
npm install -D vitest jsdom @testing-library/react @testing-library/dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true, // Testing Library limpia sola entre pruebas
},
});
Temporizadores: useDebounce
// src/hooks/useDebounce.test.ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDebounce } from './useDebounce';
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('devuelve el valor inicial sin esperar', () => {
const { result } = renderHook(() => useDebounce('ana', 300));
expect(result.current).toBe('ana');
});
it('solo cambia cuando el valor lleva el retardo sin moverse', () => {
const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
initialProps: { valor: 'a' },
});
rerender({ valor: 'an' });
act(() => vi.advanceTimersByTime(200));
rerender({ valor: 'ana' }); // reinicia la espera
act(() => vi.advanceTimersByTime(200));
expect(result.current).toBe('a');
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe('ana');
});
});
No esperamos 300 ms de verdad: vi.advanceTimersByTime hace avanzar un reloj falso. La prueba mira el comportamiento que importa: que una escritura reinicia la espera. Cada avance va dentro de act, porque al vencer el temporizador se llama a setRetardado.
Peticiones: useFetch
// src/hooks/useFetch.test.ts
import { renderHook, waitFor, act } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useFetch } from './useFetch';
function json(cuerpo: unknown, status = 200) {
return new Response(JSON.stringify(cuerpo), { status });
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('useFetch', () => {
it('pasa de cargando a ok', async () => {
vi.stubGlobal('fetch', vi.fn(async () => json({ nombre: 'Ana' })));
const { result } = renderHook(() => useFetch<{ nombre: string }>('/empleados/100'));
expect(result.current.estado).toBe('cargando');
await waitFor(() => expect(result.current.estado).toBe('ok'));
expect(result.current.datos).toEqual({ nombre: 'Ana' });
});
it('expone el mensaje de error de la API', async () => {
vi.stubGlobal('fetch', vi.fn(async () => json({ status: 404, mensaje: 'No existe' }, 404)));
const { result } = renderHook(() => useFetch('/empleados/999'));
await waitFor(() => expect(result.current.estado).toBe('error'));
expect(result.current.error).toBe('No existe');
});
it('no hace nada si la URL es null', () => {
const fetchFalso = vi.fn();
vi.stubGlobal('fetch', fetchFalso);
const { result } = renderHook(() => useFetch(null));
expect(result.current.estado).toBe('inactivo');
expect(fetchFalso).not.toHaveBeenCalled();
});
it('recargar repite la petición y conserva los datos mientras carga', async () => {
const fetchFalso = vi.fn(async () => json({ total: 1 }));
vi.stubGlobal('fetch', fetchFalso);
const { result } = renderHook(() => useFetch<{ total: number }>('/estadisticas'));
await waitFor(() => expect(result.current.estado).toBe('ok'));
act(() => result.current.recargar());
expect(result.current.estado).toBe('cargando');
expect(result.current.datos).toEqual({ total: 1 });
await waitFor(() => expect(result.current.estado).toBe('ok'));
expect(fetchFalso).toHaveBeenCalledTimes(2);
});
it('ignora una respuesta antigua que llega tarde', async () => {
const pendientes = new Map<string, (r: Response) => void>();
vi.stubGlobal(
'fetch',
vi.fn((url: string) => new Promise<Response>((resolver) => pendientes.set(url, resolver))),
);
const { result, rerender } = renderHook(({ url }) => useFetch<string>(url), {
initialProps: { url: '/lenta' },
});
rerender({ url: '/rapida' });
await act(async () => {
pendientes.get('/rapida')?.(json('rápida'));
});
await waitFor(() => expect(result.current.estado).toBe('ok'));
await act(async () => {
pendientes.get('/lenta')?.(json('lenta'));
});
expect(result.current.datos).toBe('rápida');
});
});
La última prueba merece atención. Dejamos las promesas en un Map para decidir nosotros el orden de llegada: primero la petición nueva y después la antigua. Sin la comprobación de signal.aborted, datos terminaría siendo 'lenta'. Reproducir una condición de carrera en el navegador es complicado, y aquí es determinista.
Sustituir fetch con vi.stubGlobal basta para un hook. Para pruebas más realistas, el módulo 22 usa MSW, que simula la API a nivel de red.
Almacenamiento: useLocalStorage
// src/hooks/useLocalStorage.test.ts
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useLocalStorage } from './useLocalStorage';
beforeEach(() => {
localStorage.clear();
});
describe('useLocalStorage', () => {
it('usa el valor inicial si no hay nada guardado', () => {
const { result } = renderHook(() => useLocalStorage('tamano', 10));
expect(result.current[0]).toBe(10);
});
it('admite una función actualizadora y escribe JSON', () => {
const { result } = renderHook(() => useLocalStorage('tamano', 10));
act(() => result.current[1]((n) => n + 5));
expect(result.current[0]).toBe(15);
expect(localStorage.getItem('tamano')).toBe('15');
});
it('sincroniza dos componentes con la misma clave', () => {
const primero = renderHook(() => useLocalStorage('tema', 'claro'));
const segundo = renderHook(() => useLocalStorage('tema', 'claro'));
act(() => primero.result.current[1]('oscuro'));
expect(segundo.result.current[0]).toBe('oscuro');
});
it('reacciona al evento storage de otra pestaña', () => {
const { result } = renderHook(() => useLocalStorage('tema', 'claro'));
act(() => {
localStorage.setItem('tema', '"oscuro"');
window.dispatchEvent(new StorageEvent('storage', { key: 'tema' }));
});
expect(result.current[0]).toBe('oscuro');
});
it('no se rompe con un valor que no es JSON', () => {
localStorage.setItem('tema', 'esto no es json');
const { result } = renderHook(() => useLocalStorage('tema', 'claro'));
expect(result.current[0]).toBe('claro');
});
});
Lo que jsdom no trae: useMediaQuery
jsdom no implementa el diseño ni las consultas de medios, y window.matchMedia no existe en él. Lo simulamos con un objeto que tenga solo lo que el hook usa:
// src/hooks/useMediaQuery.test.ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { useMediaQuery } from './useMediaQuery';
function simularMatchMedia(inicial: boolean) {
const oyentes = new Set<() => void>();
const lista = {
matches: inicial,
addEventListener: (_tipo: string, oyente: () => void) => oyentes.add(oyente),
removeEventListener: (_tipo: string, oyente: () => void) => oyentes.delete(oyente),
};
vi.stubGlobal('matchMedia', () => lista);
return {
oyentes,
cambiar(valor: boolean) {
lista.matches = valor;
oyentes.forEach((oyente) => oyente());
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('sigue los cambios de la consulta y se da de baja al desmontar', () => {
const simulacion = simularMatchMedia(false);
const { result, unmount } = renderHook(() => useMediaQuery('(max-width: 600px)'));
expect(result.current).toBe(false);
act(() => simulacion.cambiar(true));
expect(result.current).toBe(true);
unmount();
expect(simulacion.oyentes.size).toBe(0);
});
unmount y la comprobación de que no quedan oyentes verifican la baja, que es la parte más fácil de olvidar y la que causa fugas de memoria.
Resultado real de npx vitest run, con estos cuatro ficheros:
✓ src/hooks/useLocalStorage.test.ts > useLocalStorage > usa el valor inicial si no hay nada guardado
✓ src/hooks/useLocalStorage.test.ts > useLocalStorage > admite una función actualizadora y escribe JSON
✓ src/hooks/useLocalStorage.test.ts > useLocalStorage > sincroniza dos componentes con la misma clave
✓ src/hooks/useLocalStorage.test.ts > useLocalStorage > reacciona al evento storage de otra pestaña
✓ src/hooks/useLocalStorage.test.ts > useLocalStorage > no se rompe con un valor que no es JSON
✓ src/hooks/useFetch.test.ts > useFetch > pasa de cargando a ok
✓ src/hooks/useDebounce.test.ts > useDebounce > devuelve el valor inicial sin esperar
✓ src/hooks/useDebounce.test.ts > useDebounce > solo cambia cuando el valor lleva el retardo sin moverse
✓ src/hooks/useFetch.test.ts > useFetch > expone el mensaje de error de la API
✓ src/hooks/useFetch.test.ts > useFetch > no hace nada si la URL es null
✓ src/hooks/useFetch.test.ts > useFetch > recargar repite la petición y conserva los datos mientras carga
✓ src/hooks/useFetch.test.ts > useFetch > ignora una respuesta antigua que llega tarde
✓ src/hooks/useMediaQuery.test.ts > sigue los cambios de la consulta y se da de baja al desmontar
Test Files 4 passed (4)
Tests 13 passed (13)
💡 Consejo: Prueba lo que el hook promete, no cómo lo hace. Si cambias
useLocalStoragedeuseStateauseSyncExternalStore, las pruebas de lectura y escritura no deberían tocarse. Justo eso es lo que hicimos en la sección 7: la primera versión falla en la de sincronización, que describe un fallo real.
15. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| Dos componentes con el mismo hook no ven el mismo dato | El hook comparte lógica, no estado | Eleva el estado, usa Context o un almacén externo |
The result of getSnapshot should be cached | getSnapshot devuelve un objeto nuevo | Devuelve un primitivo o una referencia guardada |
| El hook se suscribe en cada render | subscribe o el callback ref son funciones nuevas | Defínelos fuera o usa useCallback |
| Una función recibida por el hook ve datos antiguos | Cierre obsoleto | useEffectEvent o una ref actualizada en useLayoutEffect |
| El intervalo nunca vence | La función recibida está en las dependencias | Saca la función de las dependencias, como arriba |
El tipo devuelto es (string \| (() => void))[] | Falta as const en el array | Añade as const o declara el tipo de retorno |
| Un efecto externo se repite sin parar | El hook devuelve una función sin useCallback | Estabiliza la función |
useState o useEffect no permitidos en esta función | Falta el prefijo use | Renombra a useAlgo |
Missing getServerSnapshot | Sin tercer argumento en servidor | Añade getServerSnapshot |
El dato de localStorage no se actualiza en la misma pestaña | El evento storage solo llega a las demás pestañas | Emite un evento propio tras escribir |
matchMedia is not a function en pruebas | jsdom no lo implementa | Simúlalo con vi.stubGlobal |
16. Ejercicios
✏️ Ejercicio: Crea un proyecto o reutiliza el del módulo. Los ejercicios resueltos usan los mismos ficheros
src/api.ts,src/hooks/useFetch.tsysrc/hooks/useIntervalo.tsde las secciones anteriores.
Ejercicio 1: useAlternar
Escribe un hook para los mil casos de “abierto o cerrado”. Debe devolver un objeto con activo, alternar, activar y desactivar, y las tres funciones deben ser estables.
Ver solución
// src/hooks/useAlternar.ts
import { useCallback, useState } from 'react';
export function useAlternar(inicial = false) {
const [activo, setActivo] = useState(inicial);
const alternar = useCallback(() => setActivo((actual) => !actual), []);
const activar = useCallback(() => setActivo(true), []);
const desactivar = useCallback(() => setActivo(false), []);
return { activo, alternar, activar, desactivar };
}
Usar la forma actualizadora en alternar permite que useCallback tenga dependencias vacías. Si escribieras setActivo(!activo), necesitarías activo en las dependencias y la función dejaría de ser estable.
Devolver un objeto y no un array es una decisión razonable aquí, porque son cuatro elementos y lo normal es usar solo dos.
Ejercicio 2: useTituloDocumento
Escribe un hook que ponga el título de la pestaña y restaure el anterior cuando el componente se desmonte. Debe funcionar si el título cambia.
Ver solución
// src/hooks/useTituloDocumento.ts
import { useEffect } from 'react';
export function useTituloDocumento(titulo: string) {
useEffect(() => {
const anterior = document.title;
document.title = titulo;
return () => {
document.title = anterior;
};
}, [titulo]);
}
La limpieza se ejecuta antes de cada nuevo efecto y al desmontar. Con la prueba hecha, tras desmontar, document.title vuelve a Base.
Si todos los componentes usan React 19, también puedes escribir directamente <title> en el JSX, como viste en el módulo 07. El hook sigue siendo útil en versiones anteriores y cuando quieres restaurar el título.
Ejercicio 3: useTamanoVentana
Escribe un hook con useSyncExternalStore que devuelva { ancho, alto } de la ventana. Ojo: es un objeto, así que tendrás que resolver el error típico 2.
Ver solución
// src/hooks/useTamanoVentana.ts
import { useSyncExternalStore } from 'react';
interface Tamano {
ancho: number;
alto: number;
}
const EN_SERVIDOR: Tamano = { ancho: 0, alto: 0 };
let ultimo = EN_SERVIDOR;
function leer(): Tamano {
const ancho = window.innerWidth;
const alto = window.innerHeight;
// Solo se crea un objeto nuevo si los números han cambiado
if (ancho !== ultimo.ancho || alto !== ultimo.alto) {
ultimo = { ancho, alto };
}
return ultimo;
}
function suscribir(aviso: () => void) {
window.addEventListener('resize', aviso);
return () => window.removeEventListener('resize', aviso);
}
export function useTamanoVentana() {
return useSyncExternalStore(suscribir, leer, () => EN_SERVIDOR);
}
leer solo crea un objeto cuando los números cambian, así que mientras no haya un resize, devuelve la misma referencia y Object.is da verdadero. La prueba lo confirma: tras un nuevo render, result.current es el mismo objeto, y tras un resize de 800 a 500 el ancho se actualiza.
Si solo necesitas el ancho, es más sencillo devolver un número: () => window.innerWidth. Además, evita renders cuando solo cambia la altura.
Ejercicio 4: useCuentaAtras 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: importa la versión compatible de
useIntervalode la sección 10, basada en una ref. El resto del hook no cambia.
Sobre useIntervalo, escribe un hook que reciba unos segundos y devuelva restantes, enMarcha, terminada, iniciar, pausar y reiniciar. Debe detenerse en cero.
Ver solución
// src/hooks/useCuentaAtras.ts
import { useState } from 'react';
import { useIntervalo } from './useIntervalo';
export function useCuentaAtras(segundos: number) {
const [restantes, setRestantes] = useState(segundos);
const [enMarcha, setEnMarcha] = useState(false);
useIntervalo(() => setRestantes((r) => Math.max(0, r - 1)), enMarcha && restantes > 0 ? 1000 : null);
return {
restantes,
enMarcha: enMarcha && restantes > 0,
terminada: restantes === 0,
iniciar: () => setEnMarcha(true),
pausar: () => setEnMarcha(false),
reiniciar: () => {
setEnMarcha(false);
setRestantes(segundos);
},
};
}
enMarcha y terminada se calculan: nada los guarda de más. Cuando restantes llega a 0, el segundo argumento pasa a null y el intervalo se limpia solo.
El Math.max no es un capricho. Nuestra primera versión hacía r - 1, y la prueba del ejercicio 7 falló con expected -8 to be +0. En un navegador, cada segundo hay un render y el intervalo se detiene a tiempo. Pero si varias vencidas se agrupan antes de un render, como hace act al avanzar diez segundos de golpe, el contador sigue bajando. Un hook debe mantener su invariante sin depender de cuándo renderice React.
Ejercicio 5: useClickFuera 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: guarda
alClicarFueraen una ref actualizada enuseLayoutEffecty llama aref.current()dentro del efecto, como en la versión compatible deuseIntervalo.
Escribe un hook que reciba una ref y una función, y llame a esa función cuando el usuario pulse fuera del elemento. Sirve para cerrar menús y desplegables. Usa useEffectEvent.
Ver solución
// src/hooks/useClickFuera.ts
import { useEffect, useEffectEvent } from 'react';
import type { RefObject } from 'react';
export function useClickFuera(ref: RefObject<HTMLElement | null>, alClicarFuera: () => void) {
const avisar = useEffectEvent(alClicarFuera);
useEffect(() => {
function comprobar(evento: PointerEvent) {
const nodo = ref.current;
if (nodo !== null && !nodo.contains(evento.target as Node)) {
avisar();
}
}
document.addEventListener('pointerdown', comprobar);
return () => document.removeEventListener('pointerdown', comprobar);
}, [ref]);
}
Se escucha en document y se pregunta con contains si el objetivo está dentro del nodo. Se usa pointerdown en vez de click para cubrir ratón, táctil y lápiz, y para actuar antes de que un botón externo reciba su propio clic. La función se ejecuta 0 veces al pulsar dentro y 1 al pulsar fuera, según la prueba.
ref debe estar en las dependencias. Aunque casi nunca cambie, el linter no puede saberlo, porque llega como parámetro.
Ejercicio 6: useListaPaginada
Sobre useFetch, escribe un hook genérico que reciba el nombre de una ruta de la API y un tamaño de página. Debe devolver los elementos, la página, el total de páginas y las funciones para avanzar y retroceder. Pruébalo con los empleados.
Ver solución
// src/hooks/useListaPaginada.ts
import { useState } from 'react';
import { API } from '../api';
import type { Pagina } from '../api';
import { useFetch } from './useFetch';
export function useListaPaginada<T>(ruta: string, tamano = 5) {
const [pagina, setPagina] = useState(1);
const { estado, datos, error } = useFetch<Pagina<T>>(
`${API}/${ruta}?pagina=${pagina}&tamano=${tamano}`,
);
const paginas = datos?.paginas ?? 1;
return {
elementos: datos?.contenido ?? [],
pagina,
paginas,
estado,
error,
hayAnterior: pagina > 1,
haySiguiente: pagina < paginas,
anterior: () => setPagina((p) => Math.max(1, p - 1)),
siguiente: () => setPagina((p) => Math.min(paginas, p + 1)),
};
}
// src/App.tsx
import type { Empleado } from './api';
import { useListaPaginada } from './hooks/useListaPaginada';
export default function App() {
const lista = useListaPaginada<Empleado>('empleados', 4);
return (
<section className="tarjeta">
<h2>Plantilla</h2>
{lista.error && <p className="aviso error">{lista.error}</p>}
<ul>
{lista.elementos.map((e) => (
<li key={e.empId}>{e.nombre} {e.apellidos}</li>
))}
</ul>
<div className="fila">
<button disabled={!lista.hayAnterior} onClick={lista.anterior}>Anterior</button>
<span>Página {lista.pagina} de {lista.paginas}</span>
<button disabled={!lista.haySiguiente} onClick={lista.siguiente}>Siguiente</button>
</div>
</section>
);
}
La salida real con 4 empleados por página muestra Página 1 de 6 con Ana García López en primer lugar y, tras pulsar Siguiente, Página 2 de 6 con Laura Fernández Moreno.
El parámetro de tipo <T> del hook se pasa directamente a useFetch, de modo que elementos queda tipado como Empleado[]. Mientras carga la página siguiente, datos conserva la anterior gracias al diseño de useFetch, y la lista no parpadea.
Ejercicio 7: probar los hooks
Escribe pruebas para useAlternar y useCuentaAtras. Comprueba que la función de useAlternar es estable y que la cuenta atrás se detiene en cero aunque pase mucho tiempo.
Ver solución
// src/hooks/ejercicios.test.ts
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { useAlternar } from './useAlternar';
import { useCuentaAtras } from './useCuentaAtras';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('useAlternar cambia el valor y mantiene estable la función', () => {
const { result } = renderHook(() => useAlternar());
const primeraFuncion = result.current.alternar;
act(() => result.current.alternar());
expect(result.current.activo).toBe(true);
expect(result.current.alternar).toBe(primeraFuncion);
});
it('useCuentaAtras descuenta, se detiene en cero y puede reiniciarse', () => {
const { result } = renderHook(() => useCuentaAtras(3));
act(() => result.current.iniciar());
act(() => vi.advanceTimersByTime(1000));
expect(result.current.restantes).toBe(2);
act(() => vi.advanceTimersByTime(10000));
expect(result.current.restantes).toBe(0);
expect(result.current.terminada).toBe(true);
expect(result.current.enMarcha).toBe(false);
act(() => result.current.reiniciar());
expect(result.current.restantes).toBe(3);
});
primeraFuncion guarda la función, no result, y por eso toBe compara identidades entre renders. Ten en cuenta que act(() => vi.advanceTimersByTime(10000)) ejecuta diez vencimientos antes de que React renderice, y esto es lo que destapó el error descrito en el ejercicio 4.
Ejercicio 8: encuentra los errores
Este código tiene cuatro problemas. Localízalos sin ejecutarlo y corrígelos.
export function reloj() {
const [hora, setHora] = useState(new Date());
useEffect(() => {
setInterval(() => setHora(new Date()), 1000);
});
return { hora, texto: hora.toLocaleTimeString() };
}
Ver solución
- El nombre. No empieza por
use, y el linter da dos erroresrules-of-hooks: uno poruseStatey otro poruseEffect. - Sin limpieza. El intervalo nunca se cancela: sigue vivo tras desmontar.
- Sin array de dependencias. El efecto se ejecuta tras cada render. Como cada segundo hay un render, cada segundo se añade un intervalo más. El linter no detecta este caso.
useState(new Date())sin inicialización perezosa. Crea unDateen cada render, aunque solo importa el primero. Es un fallo menor.
Los puntos 2 y 3 juntos hacen que el número de intervalos crezca sin límite.
// src/hooks/useReloj.ts
import { useEffect, useState } from 'react';
export function useReloj() {
const [ahora, setAhora] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setAhora(new Date()), 1000);
return () => clearInterval(id);
}, []);
return ahora;
}
texto ya no se devuelve, porque quien lo usa puede calcularlo a partir de ahora con el formato que quiera. La prueba comprueba con vi.getTimerCount() que hay 1 temporizador montado y 0 tras desmontar.
Ejercicio 9 (propuesto): useHistorial
Escribe un hook con useReducer que guarde un valor con su historial: valor, cambiar, deshacer, rehacer, puedeDeshacer y puedeRehacer. Pruébalo con un campo de nombre de departamento.
Ejercicio 10 (propuesto): useMutacion
Escribe un hook complementario a useFetch para escribir en la API: recibe una función asíncrona y devuelve ejecutar, estado y error. Úsalo para crear un departamento con POST /api/departamentos, enviando el token en la cabecera Authorization. El módulo 16 enseña la forma nativa de React para esto.
Ejercicio 11 (propuesto): useFavoritos
Con useLocalStorage, escribe un hook que guarde los identificadores de empleados favoritos y exponga esFavorito(id) y alternar(id). Después compón con él una versión de useBusquedaEmpleados que muestre primero los favoritos.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Hook personalizado | function useAlgo() {} | Una función con hooks dentro, que se ejecuta en el render de quien la llama |
| Prefijo | use + mayúscula | Activa las reglas de los hooks en el linter. No lo uses sin hooks dentro |
| Retorno | Valor, [a, b] as const u objeto | Array si se parece a useState, objeto si son muchos datos |
| Compartir | Lógica sí, estado no | Cada llamada tiene sus propias celdas de estado |
| Dato externo | useSyncExternalStore | React lee y se suscribe: no hay copia en useState |
| Instantánea | getSnapshot | Si no cambia, debe devolver lo mismo según Object.is |
| Suscripción | subscribe | Función estable que devuelve la baja. Fuera del componente o con useCallback |
| Servidor | getServerSnapshot | Valor para el HTML y la hidratación |
| Peticiones | useFetch | Estado derivado de una clave, AbortController y recargar estable |
| Rebote | useDebounce | La limpieza del efecto cancela el temporizador anterior |
| Persistencia | useLocalStorage | La instantánea es la cadena. storage solo llega a las demás pestañas |
| Consultas de medios | useMediaQuery | Solo si cambia el árbol. Si no, CSS |
| Composición | Hook dentro de hook | Genéricos abajo, de negocio arriba, con datos fluyendo por argumentos |
| Funciones recibidas | useEffectEvent 🆕 19.2 | Siempre la última versión, sin ser dependencia. Antes, una ref |
| Refs devueltas | Callback ref con limpieza 🆕 19.0 | Se entera de cuándo aparece el nodo |
| Estabilidad | useCallback, useMemo | Lo que se devuelve puede acabar en dependencias |
| Depuración | useDebugValue | Etiqueta en React DevTools |
| Pruebas | renderHook, act, waitFor | Lee siempre result.current, y simula lo que jsdom no tiene |