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 useSyncExternalStore para leer datos que viven fuera de React
  • Construir paso a paso useOnlineStatus, useFetch, useDebounce, useLocalStorage y useMediaQuery
  • Componer hooks genéricos en hooks de negocio para la aplicación EMPRESA
  • Recibir funciones en un hook sin caer en cierres obsoletos, con useEffectEvent o con una ref
  • Devolver funciones y objetos estables cuando importa, y etiquetar hooks con useDebugValue
  • Probar hooks de forma aislada con renderHook, act, waitFor y 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:

  1. Está repetido. Si un día corriges un fallo, tienes que acordarte de cada copia.
  2. Mezcla niveles. BotonGuardar trata de guardar empleados, pero la mayor parte de su código habla de addEventListener.
  3. 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.

Ficha de empleado sin conexión, con un aviso rojo y el botón Reconectando desactivado

Figura 1. BarraEstado y BotonGuardar reaccionan al mismo evento mediante el mismo hook.

A la izquierda dos componentes con la misma lógica copiada; a la derecha los dos llaman a useOnlineStatus

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 useState y useEffect de useOnlineStatus son simplemente llamadas hechas durante el render de BarraEstado, 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.
  • cargarNombre falla por el nombre. El mismo código con useNombre sería correcto.
  • useDato falla por llamar a useState dentro de un if. 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.
  • useFormatoEuros no 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.

A la izquierda dos componentes con estado propio; a la derecha dos componentes leen un único almacén externo

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 useCampo el tipo del evento es ChangeEvent, y en el formulario el del envío es SubmitEvent, ambos importados de react. En @types/react 19.3 el tipo FormEvent está marcado como obsoleto: su comentario dice que no existe en realidad y remite a ChangeEvent, InputEvent o SubmitEvent. Importa SubmitEvent desde react de 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í que useState(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
  );
}

Secuencia entre React y el almacén: leer, suscribirse, recibir el aviso y volver a leer

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.innerWidth es perfecto.
  • Devolver un objeto que el almacén ya tenga guardado y que solo sustituya al cambiar. Así lo hace crearAlmacen má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:

  1. Vuelva a pedir cuando cambie la URL, cancelando la petición anterior.
  2. Acepte null como URL para decir “todavía no pidas nada”.
  3. Distinga cuatro situaciones: inactivo, cargando, correcto y error.
  4. Muestre el mensaje de los errores de la API EMPRESA, como { "status": 404, "mensaje": "..." }.
  5. Permita recargar a mano.
  6. 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 />
    </>
  );
}

Lista en estado cargando con los datos anteriores atenuados, y debajo un error de empleado no encontrado

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: useFetch es 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

Buscador con el texto garcia y su lista de resultados

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 2 en uno, el otro sigue en 1.
  • Otra pestaña tampoco se entera.
  • JSON.parse puede 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 getSnapshot cumple la regla sin caché alguna. JSON.parse se hace en useMemo, solo cuando la cadena cambia. Si hiciéramos el parse dentro de getSnapshot tendríamos el error típico 2 con cualquier objeto.
  • guardar lee localStorage, no el valor del render. Así la función actualizadora siempre parte del dato real, incluso con dos llamadas seguidas.
  • El cast de cambio. TypeScript no puede saber que T no es una función. Es la misma ambigüedad que tiene useState, y por eso este hook no sirve para guardar funciones. De todos modos, JSON no las admite.
  • inicial forma parte de las dependencias. Pásale una constante o un valor primitivo. Si escribes useLocalStorage('filtros', {}) directamente, cada render creará un objeto nuevo y valor y guardar cambiará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.

Pestaña de preferencias con 25 filas y contador 2, cambiados desde otra pestaña

Figura 7. Captura de la segunda pestaña. Nadie la ha tocado: el evento storage la ha actualizado.

⚠️ Importante: localStorage no es un sitio seguro. Cualquier script de la página puede leerlo, así que no guardes ahí contraseñas ni datos personales. Además, setItem puede lanzar QuotaExceededError si se llena el espacio. Para datos grandes o críticos, envuelve la escritura en try/catch y 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>
  );
}

Vista ancha de la tabla con los salarios más altos en tema claro

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

Vista estrecha con fichas en tema oscuro

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 @media en 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>
  );
}

Directorio de EMPRESA con la búsqueda escrita y una tabla de resultados

Figura 10. El directorio recuerda la búsqueda, espera a que dejes de escribir y se pausa sin conexión.

Capas: el componente usa useBusquedaEmpleados, que usa cuatro hooks genéricos, que usan APIs del navegador

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, useEffectEvent dentro de componentes envueltos en memo o forwardRef lee 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 null al desmontar. Si usas una versión anterior: guarda el ResizeObserver en una useRef y desconéctalo en la rama nodo === 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 useMemo si 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 useLocalStorage de useState a useSyncExternalStore, 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.ts y src/hooks/useIntervalo.ts de 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 useIntervalo de 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 alClicarFuera en una ref actualizada en useLayoutEffect y llama a ref.current() dentro del efecto, como en la versión compatible de useIntervalo.

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
  1. El nombre. No empieza por use, y el linter da dos errores rules-of-hooks: uno por useState y otro por useEffect.
  2. Sin limpieza. El intervalo nunca se cancela: sigue vivo tras desmontar.
  3. 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.
  4. useState(new Date()) sin inicialización perezosa. Crea un Date en 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