En 04-02 y 04-04 quedó explicado por qué existen los hooks: antes de 2019, reutilizar lógica con estado entre componentes obligaba a envolverlos en HOC o render props, con el infierno de envoltorios que aquello producía. Los cinco hooks que has estudiado en este módulo resuelven el problema de puertas adentro, pero todavía no has usado la pieza que cierra el círculo: la posibilidad de escribir tus propios hooks. El buscador con retardo de 05-02, la suscripción al evento de conexión, el temporizador de disponibilidad, el tema sincronizado con localStorage de 05-04… son fragmentos que se repiten en distintos componentes de CicloUrbano y que hoy tendrías que copiar y pegar. En esta lección aprenderás a extraerlos a funciones reutilizables, construirás una colección completa de hooks para el proyecto, y —lo más importante— entenderás la propiedad que más se malinterpreta: un hook personalizado comparte lógica, no estado.

Contenido

  1. Qué es un hook personalizado
  2. Comparte lógica, no estado: la demostración
  3. Cuándo extraer un hook
  4. Cómo se extrae, paso a paso
  5. useAlternar: el más simple
  6. useAlmacenLocal: estado sincronizado con el navegador
  7. useDebounce: retrasar el término de búsqueda
  8. useFetchBicicletas: carga de datos con cancelación
  9. useEventoTeclado y useAnchoVentana
  10. Diseñar la API de un hook
  11. Las reglas siguen vigentes
  12. useId y otros hooks de apoyo

  1. Qué es un hook personalizado

Un hook personalizado es una función de JavaScript cuyo nombre empieza por use y que llama a otros hooks.

No hay nada más. Ni una API especial, ni un registro, ni una configuración. Si escribes una función llamada useAlgo que dentro usa useState o useEffect, has creado un hook.

// src/hooks/useContadorRenders.js
import { useRef, useEffect } from 'react';

export function useContadorRenders(nombre) {
  const renders = useRef(0);

  useEffect(() => {
    renders.current += 1;
    console.log(`${nombre}: render nº ${renders.current}`);
  });

  return renders.current;
}

Dos cosas hacen que el prefijo use no sea opcional:

  • Es lo que le dice a React y al linter que esa función sigue las reglas de los hooks (04-04). Sin el prefijo, eslint-plugin-react-hooks no puede comprobar que no la llamas dentro de un if o de un bucle, y perderías la red de seguridad.
  • Es lo que le dice a quien lee tu código que esa función no es una utilidad corriente: tiene estado, participa del ciclo de render y no se puede llamar desde cualquier sitio.

Al revés también vale: si una función no llama a ningún hook, no la llames useAlgo. validarReserva y clases son funciones puras normales, y por eso viven en src/utilidades/ y no en src/hooks/.

  1. Comparte lógica, no estado: la demostración

Esta es la propiedad que más confusión genera, así que vamos a demostrarla.

// src/hooks/useContador.js
import { useState } from 'react';

export function useContador(inicial = 0) {
  const [valor, setValor] = useState(inicial);
  const incrementar = () => setValor((previo) => previo + 1);
  const reiniciar = () => setValor(inicial);
  return { valor, incrementar, reiniciar };
}

Ahora dos componentes que lo usan, en la misma pantalla:

function PanelIzquierdo() {
  const { valor, incrementar } = useContador();
  return <button type="button" onClick={incrementar}>Izquierdo: {valor}</button>;
}

function PanelDerecho() {
  const { valor, incrementar } = useContador();
  return <button type="button" onClick={incrementar}>Derecho: {valor}</button>;
}

Pulsa cinco veces el botón izquierdo. Marcará «Izquierdo: 5» y «Derecho: 0». Los dos componentes comparten la receta, no el bote.

flowchart TD
    H["useContador()<br/><i>la lógica: una sola definición</i>"]
    H -.->|"se ejecuta dentro de"| PI["PanelIzquierdo<br/><b>su propio useState → 5</b>"]
    H -.->|"se ejecuta dentro de"| PD["PanelDerecho<br/><b>su propio useState → 0</b>"]
    style H fill:#e0f2fe
    style PI fill:#dcfce7
    style PD fill:#fde68a

La razón es la del mecanismo de 04-04: cada componente tiene su propia lista ordenada de celdas de hooks. Cuando PanelIzquierdo llama a useContador, el useState de dentro ocupa una celda de PanelIzquierdo; cuando lo llama PanelDerecho, ocupa una celda de PanelDerecho. Son almacenes distintos.

Un hook personalizado es una plantilla de comportamiento, no un almacén compartido. Cada llamada crea un estado independiente.

Y de ahí se deduce la consecuencia práctica: si lo que quieres es que varios componentes vean el mismo dato, un hook personalizado no basta. Necesitas elevar el estado (04-01) o un contexto (05-04). Lo habitual es combinarlos: el estado compartido vive en un proveedor y el hook de acceso —useUsuario, useTema, useReservas— es un hook personalizado que lo lee. De hecho, ya has escrito tres hooks personalizados sin llamarlos así.

  1. Cuándo extraer un hook

Señal Ejemplo en CicloUrbano
La misma pareja useState + useEffect aparece en dos o más componentes La suscripción a resize en ListaBicicletas y en PanelResumen
Un componente tiene tanta fontanería que cuesta encontrar el JSX PanelActividad con carga, error, cancelación y bandera ignorar
Quieres probar la lógica sin montar la interfaz La validación con retardo del buscador
Un nombre describiría bien lo que hace ese bloque useAlmacenLocal, useDebounce, useEventoTeclado
El mismo bug hay que arreglarlo en varios sitios Olvidar removeEventListener en tres componentes distintos

Y cuándo no extraer:

  • Cuando se usa una sola vez y el bloque es corto. Un useEffect de tres líneas dentro del componente se lee mejor donde está.
  • Cuando el «hook» solo envuelve otro sin aportar nada. function useNombre() { return useState(''); } es indirección pura: quien lo lee tiene que abrir otro fichero para descubrir que no hace nada.
  • Cuando la lógica no usa ningún hook. Eso es una función normal; llevarla a src/utilidades/ es la decisión correcta.

  1. Cómo se extrae, paso a paso

Partimos del AvisoConexion de 05-02 y lo convertimos en hook. El proceso siempre es el mismo:

Paso 1: identificar el bloque completo. Estado, efecto y todo lo que dependa de ellos.

function AvisoConexion() {
  const [enLinea, setEnLinea] = useState(() => navigator.onLine);   // ← bloque

  useEffect(() => {                                                  // ← bloque
    function manejarEnLinea() { setEnLinea(true); }
    function manejarSinLinea() { setEnLinea(false); }
    window.addEventListener('online', manejarEnLinea);
    window.addEventListener('offline', manejarSinLinea);
    return () => {
      window.removeEventListener('online', manejarEnLinea);
      window.removeEventListener('offline', manejarSinLinea);
    };
  }, []);

  if (enLinea) return null;                                          // ← esto NO: es interfaz
  return <Aviso tono="advertencia">Sin conexión…</Aviso>;
}

Paso 2: mover el bloque a una función con prefijo use, en src/hooks/.

Paso 3: decidir qué devuelve. Lo mínimo que necesita el componente: aquí, un booleano.

// src/hooks/useEstadoConexion.js
import { useState, useEffect } from 'react';

/**
 * Indica si el navegador tiene conexión a la red.
 * Devuelve: booleano
 */
export function useEstadoConexion() {
  const [enLinea, setEnLinea] = useState(() => navigator.onLine);

  useEffect(() => {
    function manejarEnLinea() { setEnLinea(true); }
    function manejarSinLinea() { setEnLinea(false); }

    window.addEventListener('online', manejarEnLinea);
    window.addEventListener('offline', manejarSinLinea);

    return () => {
      window.removeEventListener('online', manejarEnLinea);
      window.removeEventListener('offline', manejarSinLinea);
    };
  }, []);

  return enLinea;
}

Paso 4: el componente se queda solo con la interfaz.

// src/componentes/AvisoConexion.jsx
import { useEstadoConexion } from '../hooks/useEstadoConexion.js';
import Aviso from './Aviso.jsx';

function AvisoConexion() {
  const enLinea = useEstadoConexion();

  if (enLinea) return null;
  return (
    <Aviso tono="advertencia">
      Sin conexión. Puedes consultar el catálogo, pero no confirmar reservas.
    </Aviso>
  );
}

export default AvisoConexion;

De veinte líneas a seis, y ahora el botón «Confirmar reserva» de PanelReserva puede desactivarse sin conexión con una sola línea: const enLinea = useEstadoConexion();.

  1. useAlternar: el más simple

Empezamos la colección. Un booleano con tres operaciones con nombre, que aparece en el Modal, el Acordeon y el PanelAvanzado.

// src/hooks/useAlternar.js
import { useState, useCallback } from 'react';

/**
 * Booleano con operaciones con nombre.
 * Parámetros:
 *  - inicial (booleano, opcional, por defecto false)
 * Devuelve: [valor, { activar, desactivar, alternar }]
 */
export function useAlternar(inicial = false) {
  const [valor, setValor] = useState(inicial);

  const activar = useCallback(() => setValor(true), []);
  const desactivar = useCallback(() => setValor(false), []);
  const alternar = useCallback(() => setValor((previo) => !previo), []);

  return [valor, { activar, desactivar, alternar }];
}

Línea a línea:

  • useState(inicial) guarda el booleano. Todo lo demás son envoltorios sobre su actualizador.
  • activar y desactivar usan valor directo porque no dependen del anterior; alternar usa la forma funcional porque sí depende (05-01).
  • useCallback con [] hace que las tres funciones sean estables entre renders. Sin él, cada render devolvería funciones nuevas, y un componente que las pusiera en las dependencias de un efecto (05-02) lo reejecutaría en bucle. Esta es la única concesión al Módulo 8 en toda la lección, y es de corrección, no de rendimiento: los hooks personalizados deben devolver valores estables (apartado 10).
  • Devuelve un array porque la primera posición es el valor principal y la segunda un grupo de acciones; así quien lo usa elige los nombres.
// src/componentes/Acordeon.jsx (fragmento)
import { useAlternar } from '../hooks/useAlternar.js';

function Acordeon({ titulo, children }) {
  const [abierto, { alternar }] = useAlternar(false);

  return (
    <section>
      <button type="button" onClick={alternar} aria-expanded={abierto}>
        {titulo}
      </button>
      {abierto && <div>{children}</div>}
    </section>
  );
}

  1. useAlmacenLocal: estado sincronizado con el navegador

Combina useState y useEffect para que un dato sobreviva a la recarga de la página. Lo usa ProveedorTema (05-04), y lo usará el borrador de reserva.

// src/hooks/useAlmacenLocal.js
import { useState, useEffect } from 'react';

/**
 * Estado sincronizado con localStorage.
 * Parámetros:
 *  - clave    (cadena, obligatoria): clave de localStorage
 *  - inicial  (cualquiera, opcional): valor si no hay nada guardado
 * Devuelve: [valor, establecerValor]
 */
export function useAlmacenLocal(clave, inicial = null) {
  const [valor, setValor] = useState(() => {
    // Inicialización perezosa: solo se lee del navegador una vez (05-01)
    try {
      const guardado = localStorage.getItem(clave);
      return guardado === null ? inicial : JSON.parse(guardado);
    } catch {
      // JSON corrupto o localStorage bloqueado (modo privado, cuotas)
      return inicial;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(clave, JSON.stringify(valor));
    } catch {
      // Sin espacio o sin permisos: la aplicación debe seguir funcionando
    }
  }, [clave, valor]);

  return [valor, setValor];
}

Los detalles que separan un hook de juguete de uno utilizable:

  • La lectura va en el inicializador perezoso, no en un efecto. Si estuviera en un efecto, el primer render mostraría el valor inicial y el segundo el guardado: un parpadeo visible.
  • Los dos try/catch no son paranoia. localStorage lanza en modo privado de algunos navegadores, cuando se supera la cuota, o cuando el contenido guardado no es JSON válido porque una versión anterior de la aplicación guardó otra cosa. Un fallo aquí no debe tumbar la aplicación.
  • clave está en las dependencias del efecto porque es un valor reactivo: si el componente cambia de clave, hay que guardar en la nueva.
  • Devuelve un par posicional, igual que useState, para que el reemplazo sea inmediato: cambias useState('claro') por useAlmacenLocal('ciclourbano:tema', 'claro') y ya está.
// ProveedorTema (05-04) se simplifica así
const [tema, setTema] = useAlmacenLocal('ciclourbano:tema', 'claro');

  1. useDebounce: retrasar el término de búsqueda

BuscadorBicicletas no debe filtrar en cada tecla: hay que esperar a que el usuario deje de escribir. En 05-02 lo resolviste con un efecto dentro del componente; ahora se convierte en un hook reutilizable.

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

/**
 * Devuelve una copia retrasada de un valor.
 * Parámetros:
 *  - valor     (cualquiera, obligatorio)
 *  - retardoMs (número, opcional, por defecto 400)
 * Devuelve: el valor tras `retardoMs` sin cambios
 */
export function useDebounce(valor, retardoMs = 400) {
  const [valorRetrasado, setValorRetrasado] = useState(valor);

  useEffect(() => {
    const identificador = setTimeout(() => setValorRetrasado(valor), retardoMs);
    return () => clearTimeout(identificador);
  }, [valor, retardoMs]);

  return valorRetrasado;
}

El mecanismo es el que analizaste en 05-02: la limpieza cancela el temporizador pendiente. Mientras el usuario escriba, cada tecla mata el temporizador anterior y programa otro; solo cuando pasan 400 ms sin cambios sobrevive uno y actualiza valorRetrasado.

// src/componentes/BuscadorBicicletas.jsx
import { useState, useEffect } from 'react';
import { useDebounce } from '../hooks/useDebounce.js';

/**
 * Props:
 *  - alBuscar (función, obligatoria): recibe el término retrasado
 */
function BuscadorBicicletas({ alBuscar }) {
  const [texto, setTexto] = useState('');
  const terminoRetrasado = useDebounce(texto, 400);

  useEffect(() => {
    alBuscar(terminoRetrasado);
  }, [terminoRetrasado, alBuscar]);

  return (
    <input
      type="search"
      value={texto}
      onChange={(evento) => setTexto(evento.target.value)}
      aria-label="Buscar bicicletas por modelo"
    />
  );
}

export default BuscadorBicicletas;

Fíjate en el reparto: el campo sigue siendo controlado y responde al instante (03-04), porque nadie quiere que la escritura se sienta lenta; lo que se retrasa es únicamente el aviso al padre.

  1. useFetchBicicletas: carga de datos con cancelación

El caso más largo, y el que más limpia el componente. Reutiliza todo lo de 05-02: AbortController, bandera ignorar, comprobación de respuesta.ok y una única variable de fase.

// src/hooks/useFetchBicicletas.js
import { useState, useEffect } from 'react';

/**
 * Carga las bicicletas de una estación desde la API.
 * Parámetros:
 *  - estacionId (cadena, obligatoria)
 * Devuelve: { bicicletas, cargando, error }
 */
export function useFetchBicicletas(estacionId) {
  const [bicicletas, setBicicletas] = useState([]);
  const [fase, setFase] = useState('inactivo');   // 'inactivo' | 'cargando' | 'exito' | 'error'
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!estacionId) {
      setBicicletas([]);
      setFase('inactivo');
      return;
    }

    const controlador = new AbortController();
    let ignorar = false;

    async function cargar() {
      setFase('cargando');
      setError(null);
      try {
        const respuesta = await fetch(
          `/api/estaciones/${estacionId}/bicicletas`,
          { signal: controlador.signal }
        );
        if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
        const datos = await respuesta.json();
        if (!ignorar) {
          setBicicletas(datos);
          setFase('exito');
        }
      } catch (fallo) {
        if (fallo.name === 'AbortError') return;
        if (!ignorar) {
          setError(fallo.message);
          setFase('error');
        }
      }
    }

    cargar();

    return () => {
      ignorar = true;
      controlador.abort();
    };
  }, [estacionId]);

  return { bicicletas, cargando: fase === 'cargando', error };
}

Puntos de diseño:

  • La guarda inicial (if (!estacionId)) permite usar el hook antes de que haya estación elegida. Un hook no puede llamarse condicionalmente, pero puede salir pronto por dentro: la condición está dentro del efecto, no alrededor del hook.
  • fase es interna y no se expone. Hacia fuera solo salen cargando y error, que es lo que necesita la interfaz. Ocultar el detalle es parte del diseño de la API.
  • Devuelve un objeto, no un array, porque son tres valores sin un orden natural (apartado 10).

El componente queda irreconocible de limpio:

// src/componentes/PanelActividad.jsx
import { useFetchBicicletas } from '../hooks/useFetchBicicletas.js';
import ListaBicicletas from './ListaBicicletas.jsx';
import Aviso from './Aviso.jsx';

function PanelActividad({ estacionId }) {
  const { bicicletas, cargando, error } = useFetchBicicletas(estacionId);

  if (cargando) return <p aria-live="polite">Cargando bicicletas…</p>;
  if (error) return <Aviso tono="error">No se pudo cargar la estación: {error}</Aviso>;

  return <ListaBicicletas bicicletas={bicicletas} />;
}

export default PanelActividad;

Este hook es también la mejor demostración de por qué existen las bibliotecas de estado del servidor: lo que le falta —caché entre pantallas, deduplicación de peticiones idénticas, reintentos, revalidación al volver a la pestaña— no cabe en veinte líneas, y es exactamente lo que resuelven TanStack Query o SWR en 07-06.

  1. useEventoTeclado y useAnchoVentana

Dos hooks cortos que completan la colección.

// src/hooks/useEventoTeclado.js
import { useEffect, useRef } from 'react';

/**
 * Ejecuta una acción al pulsar una tecla concreta.
 * Parámetros:
 *  - tecla   (cadena, obligatoria): valor de event.key, p. ej. 'Escape'
 *  - accion  (función, obligatoria)
 *  - activo  (booleano, opcional, por defecto true)
 */
export function useEventoTeclado(tecla, accion, activo = true) {
  const accionGuardada = useRef(accion);

  // Mantiene la acción al día sin resuscribir el escuchador
  useEffect(() => {
    accionGuardada.current = accion;
  }, [accion]);

  useEffect(() => {
    if (!activo) return;

    function manejarTecla(evento) {
      if (evento.key === tecla) accionGuardada.current(evento);
    }

    window.addEventListener('keydown', manejarTecla);
    return () => window.removeEventListener('keydown', manejarTecla);
  }, [tecla, activo]);
}

El truco de accionGuardada merece explicación, porque resuelve un problema real. Si accion estuviera directamente en las dependencias del segundo efecto, cualquier componente que pasara una función en línea (() => setAbierto(false)) provocaría una desuscripción y una resuscripción en cada render. Guardando la función en una referencia (05-03) y actualizándola en un efecto aparte, el escuchador se registra una sola vez y aun así siempre llama a la versión más reciente. Es un patrón habitual, conocido informalmente como «evento efectivo».

// src/componentes/Modal.jsx (fragmento) — cerrar con Escape
import { useEventoTeclado } from '../hooks/useEventoTeclado.js';

function Modal({ titulo, children, alCerrar }) {
  useEventoTeclado('Escape', alCerrar);
  …
}
// src/hooks/useAnchoVentana.js
import { useState, useEffect } from 'react';

/**
 * Ancho actual de la ventana en píxeles.
 * Devuelve: número
 */
export function useAnchoVentana() {
  const [ancho, setAncho] = useState(() => window.innerWidth);

  useEffect(() => {
    function manejarCambioTamano() {
      setAncho(window.innerWidth);
    }

    window.addEventListener('resize', manejarCambioTamano);
    manejarCambioTamano();   // por si cambió entre el render y la suscripción

    return () => window.removeEventListener('resize', manejarCambioTamano);
  }, []);

  return ancho;
}

Este es el ejemplo con el que 04-04 abrió el módulo: aquella lógica que había que copiar en tres componentes o envolver en cuatro HOC apilados cabe hoy en quince líneas y se usa con una. La colección completa queda así:

Hook Fichero Devuelve Usado en
useAlternar src/hooks/useAlternar.js [valor, { activar, desactivar, alternar }] Modal, Acordeon, PanelAvanzado
useAlmacenLocal src/hooks/useAlmacenLocal.js [valor, establecerValor] ProveedorTema, borrador de reserva
useDebounce src/hooks/useDebounce.js El valor retrasado BuscadorBicicletas
useFetchBicicletas src/hooks/useFetchBicicletas.js { bicicletas, cargando, error } PanelActividad
useEventoTeclado src/hooks/useEventoTeclado.js Nada Modal, DialogoReserva
useAnchoVentana src/hooks/useAnchoVentana.js number ListaBicicletas, Diseno
useEstadoConexion src/hooks/useEstadoConexion.js boolean AvisoConexion, PanelReserva

  1. Diseñar la API de un hook

Un hook es una interfaz pública: alguien lo usará sin leer su interior. Estas cuatro decisiones marcan la diferencia.

Qué devolver: array u objeto

Devuelve Cuándo Ejemplo
Un valor suelto Solo hay un resultado useAnchoVentana()1280
Un array Dos valores con orden natural, y quien lo usa querrá renombrarlos useAlmacenLocal()[valor, setValor]
Un objeto Tres o más valores, o quieres poder añadir más adelante useFetchBicicletas(){ bicicletas, cargando, error }

El criterio de fondo: el array obliga a recordar el orden pero permite renombrar (por eso useState lo usa: se declaran varios por componente); el objeto documenta cada valor con su nombre y permite añadir campos sin romper a nadie, lo que con un array de cinco posiciones sería imposible.

Parámetros con valores por defecto

// ✅ El caso común no exige configuración
export function useDebounce(valor, retardoMs = 400) { … }
export function useAlternar(inicial = false) { … }
export function useEventoTeclado(tecla, accion, activo = true) { … }

Si tu hook necesita cinco parámetros, agrúpalos en un objeto de opciones con valores por defecto: useFetchBicicletas(estacionId, { reintentos = 0, intervaloMs = 0 } = {}).

Devolver valores estables

Es el error de diseño más frecuente y el más difícil de diagnosticar:

// ❌ Devuelve un objeto NUEVO en cada render
export function useReserva(bicicleta, horas) {
  return { bicicleta, horas, total: horas * bicicleta.precioHora };
}

// Quien lo use así entra en bucle infinito:
const reserva = useReserva(bici, 2);
useEffect(() => { registrar(reserva); }, [reserva]);   // 💥 reserva cambia SIEMPRE

Las funciones que devuelvas deben ser estables (useCallback con dependencias correctas, como en useAlternar) y los objetos también, o bien documenta claramente que no lo son. Los actualizadores de useState y el despachar de useReducer ya lo son de serie, así que devolverlos directamente es siempre seguro.

Mantener el hook enfocado

// ❌ Demasiadas responsabilidades: imposible de nombrar bien y de reutilizar
export function useTodoElPanel(estacionId) {
  // carga datos + gestiona el formulario + controla el modal + escucha el teclado
}

// ✅ Cuatro hooks pequeños que se combinan donde haga falta
const { bicicletas, cargando, error } = useFetchBicicletas(estacionId);
const [modalAbierto, { activar, desactivar }] = useAlternar();
useEventoTeclado('Escape', desactivar, modalAbierto);

Un hook debe poder describirse en una frase. Si al escribir su comentario necesitas una «y» y luego otra, son dos hooks.

  1. Las reglas siguen vigentes

Los hooks personalizados son hooks, así que heredan las dos reglas de 04-04 sin excepciones.

Regla 1: solo en el nivel superior. Ni dentro de condicionales, ni de bucles, ni de funciones anidadas.

// ❌ Rompe la regla 1: la lista de celdas cambiaría entre renders
function PanelEstacion({ estacionId }) {
  if (estacionId) {
    const { bicicletas } = useFetchBicicletas(estacionId);   // 💥
  }
}

// ✅ El hook se llama siempre; la condición va DENTRO del hook
function PanelEstacion({ estacionId }) {
  const { bicicletas, cargando } = useFetchBicicletas(estacionId);   // maneja null por dentro
}

Regla 2: solo desde componentes de React o desde otros hooks. Un hook personalizado puede llamar a otros hooks personalizados sin límite; lo que no puede es llamarse desde un manejador de eventos, desde una función normal o desde el cuerpo de una clase.

// ✅ Composición de hooks: perfectamente legítimo
export function useCatalogoFiltrado(estacionId, termino) {
  const { bicicletas, cargando, error } = useFetchBicicletas(estacionId);
  const terminoRetrasado = useDebounce(termino, 400);

  const visibles = bicicletas.filter((bicicleta) =>
    bicicleta.modelo.toLowerCase().includes(terminoRetrasado.toLowerCase())
  );

  return { visibles, cargando, error };
}

Este ejemplo enseña la propiedad más útil de todas: los hooks se componen. useCatalogoFiltrado no reimplementa nada; encadena dos hooks existentes y añade un filtro derivado. Igual que los componentes se construyen con otros componentes (04-02), los hooks se construyen con otros hooks.

  1. useId y otros hooks de apoyo

Un último hook de la biblioteca estándar que encaja aquí y completa la accesibilidad de 03-06: useId genera un identificador único y estable para asociar etiquetas y campos.

// src/componentes/CampoHoras.jsx
import { useId } from 'react';

function CampoHoras({ valor, alCambiar }) {
  const idCampo = useId();
  const idAyuda = `${idCampo}-ayuda`;

  return (
    <p>
      <label htmlFor={idCampo}>Horas de la reserva</label>
      <input
        id={idCampo}
        type="number"
        min="1"
        max="24"
        value={valor}
        onChange={(evento) => alCambiar(Number(evento.target.value))}
        aria-describedby={idAyuda}
      />
      <span id={idAyuda}>Entre 1 y 24 horas.</span>
    </p>
  );
}

Por qué no basta con escribir id="horas" a mano: si CampoHoras aparece dos veces en la misma página —el formulario de reserva y el de modificación—, habría dos elementos con el mismo id, y el <label> de uno activaría el campo del otro. useId garantiza unicidad por instancia. Y por qué no vale Math.random(): el identificador debe ser el mismo en el servidor y en el cliente si algún día renderizas en servidor (Módulo 10), y además debe mantenerse estable entre renders. useId cumple las dos cosas.

Regla de uso: useId es para identificadores de accesibilidad, no para claves de listas. Las key salen de los datos (03-03), nunca de un generador.

Errores Comunes y Consejos

  • Esperar que un hook comparta estado entre componentes. No lo hace: cada llamada tiene su propio estado. Para compartir, contexto (05-04) o elevar el estado (04-01).
  • No empezar el nombre por use. El linter deja de vigilar la función y las violaciones de las reglas pasan desapercibidas hasta que fallan en producción.
  • Poner use a una función que no llama a ningún hook. Es una función de utilidad: llévala a src/utilidades/.
  • Crear un hook que solo envuelve otro. useNombre() { return useState(''); } añade un fichero y no aporta nada.
  • Meter demasiadas responsabilidades. Si el nombre necesita una «y», son dos hooks.
  • Devolver objetos o funciones inestables. Rompen las dependencias de los efectos de quien lo use. useCallback para las funciones; para los objetos, o los estabilizas o lo documentas.
  • Llamar a un hook personalizado dentro de un if. La regla 1 no se relaja por ser tuyo. La condición va dentro del hook.
  • Llamarlo desde un manejador de eventos. onClick={() => useAlternar()} no es válido: los hooks se llaman durante el render.
  • Consejo: escribe el hook la segunda vez que copies el mismo bloque, no la primera. Extraer demasiado pronto produce abstracciones que no encajan en el segundo caso.
  • Consejo: documenta cada hook con un comentario que diga qué parámetros recibe y qué devuelve. Es la única documentación que leerá quien lo use.
  • Consejo: si un hook es difícil de nombrar, probablemente hace demasiadas cosas. El nombre es un buen detector de diseño.

Ejercicios

Ejercicio 1. Estos dos componentes de CicloUrbano repiten la misma lógica. Extráela a un hook useTemporizadorDisponibilidad en src/hooks/, decide qué debe devolver y reescribe los dos componentes usándolo.

function DisponibilidadEstacion({ estacion }) {
  const [plazasLibres, setPlazasLibres] = useState(estacion.plazas);
  const [ultimaLectura, setUltimaLectura] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      setPlazasLibres(consultarPlazasLibres(estacion.id));
      setUltimaLectura(new Date());
    }, 10000);
    return () => clearInterval(id);
  }, [estacion.id]);

  return <p>{estacion.nombre}: {plazasLibres} plazas libres</p>;
}

function ResumenFlota({ estacion }) {
  const [libres, setLibres] = useState(estacion.plazas);
  const [momento, setMomento] = useState(null);

  useEffect(() => {
    const id = setInterval(() => {
      setLibres(consultarPlazasLibres(estacion.id));
      setMomento(new Date());
    }, 30000);
    return () => clearInterval(id);
  }, [estacion.id]);

  return <span>Ocupación: {estacion.plazas - libres}/{estacion.plazas}</span>;
}

Ejercicio 2. Un compañero escribe este hook y se queja de que «el contador se comparte entre las dos tarjetas». Explica por qué eso es imposible, qué está observando en realidad y cómo conseguiría de verdad un contador compartido.

export function useVistas() {
  const [vistas, setVistas] = useState(0);
  const registrar = () => setVistas((previas) => previas + 1);
  return { vistas, registrar };
}

Ejercicio 3. Escribe useValorPrevio(valor), un hook que devuelva el valor que tenía su argumento en el render anterior (y undefined en el primero). Después úsalo en ContadorPlazas para mostrar si las plazas libres han subido o bajado respecto a la lectura anterior. Justifica por qué el hook usa useRef y no useState.

Soluciones

Solución 1.

// src/hooks/useTemporizadorDisponibilidad.js
import { useState, useEffect } from 'react';
import { consultarPlazasLibres } from '../utilidades/disponibilidad.js';

/**
 * Consulta periódicamente las plazas libres de una estación.
 * Parámetros:
 *  - estacion   (objeto Estacion, obligatorio)
 *  - intervaloMs (número, opcional, por defecto 10000)
 * Devuelve: { plazasLibres, ocupadas, ultimaLectura }
 */
export function useTemporizadorDisponibilidad(estacion, intervaloMs = 10000) {
  const [plazasLibres, setPlazasLibres] = useState(estacion.plazas);
  const [ultimaLectura, setUltimaLectura] = useState(null);

  useEffect(() => {
    const identificador = setInterval(() => {
      setPlazasLibres(consultarPlazasLibres(estacion.id));
      setUltimaLectura(new Date());
    }, intervaloMs);

    return () => clearInterval(identificador);
  }, [estacion.id, intervaloMs]);

  return {
    plazasLibres,
    ocupadas: estacion.plazas - plazasLibres,   // derivado: lo calcula el hook, no el componente
    ultimaLectura
  };
}
function DisponibilidadEstacion({ estacion }) {
  const { plazasLibres } = useTemporizadorDisponibilidad(estacion);
  return <p>{estacion.nombre}: {plazasLibres} plazas libres</p>;
}

function ResumenFlota({ estacion }) {
  const { ocupadas } = useTemporizadorDisponibilidad(estacion, 30000);
  return <span>Ocupación: {ocupadas}/{estacion.plazas}</span>;
}

Tres decisiones de diseño que conviene justificar: devuelve un objeto porque son tres valores sin orden natural; el intervalo es un parámetro con valor por defecto, porque los dos componentes lo necesitaban distinto y esa era la única diferencia real entre ellos; y ocupadas se calcula dentro del hook porque es un derivado que ambos consumidores querrían, y así nadie se equivoca al restar. Y una consecuencia importante de la propiedad del apartado 2: los dos componentes tienen temporizadores independientes, con frecuencias distintas y estados separados. Compartir el hook no significa compartir el temporizador.

Solución 2.

Es imposible que el contador se comparta: cada llamada a useVistas() ejecuta su propio useState, que ocupa una celda en la lista de hooks del componente que llama. Dos tarjetas son dos componentes, con dos listas y dos estados. La función useVistas es una plantilla; el estado lo crea React por instancia.

Lo que el compañero está observando será una de estas tres cosas:

  1. No hay dos instancias, sino una. Si las dos «tarjetas» son en realidad el mismo componente al que se le cambian las props, comparten estado porque son la misma instancia. Se resuelve con la prop key (05-01).
  2. El estado no está en el hook, sino más arriba. Si el valor viene de un contexto (05-04), es compartido por diseño y el hook solo lo lee.
  3. Ha declarado el estado fuera del hook, en el ámbito del módulo, que sí es compartido y además no provoca renders:
// ❌ Esto sí comparte, y funciona mal: no repinta a nadie
let vistas = 0;
export function useVistas() {
  const registrar = () => { vistas += 1; };
  return { vistas, registrar };
}

Para conseguir un contador de verdad compartido y reactivo, la respuesta es el contexto:

// src/contextos/ContextoVistas.jsx
import { createContext, useContext, useState } from 'react';

const ContextoVistas = createContext(null);

export function ProveedorVistas({ children }) {
  const [vistas, setVistas] = useState(0);
  const registrar = () => setVistas((previas) => previas + 1);

  return <ContextoVistas value={{ vistas, registrar }}>{children}</ContextoVistas>;
}

export function useVistas() {
  const contexto = useContext(ContextoVistas);
  if (contexto === null) throw new Error('useVistas debe usarse dentro de <ProveedorVistas>');
  return contexto;
}

Ahora todas las tarjetas envueltas por <ProveedorVistas> leen y modifican el mismo contador. Fíjate en que useVistas sigue siendo un hook personalizado: lo que cambia no es el hook, sino dónde vive el estado.

Solución 3.

// src/hooks/useValorPrevio.js
import { useRef, useEffect } from 'react';

/**
 * Devuelve el valor que tenía el argumento en el render anterior.
 * Parámetros:
 *  - valor (cualquiera)
 * Devuelve: el valor previo, o undefined en el primer render
 */
export function useValorPrevio(valor) {
  const referencia = useRef(undefined);

  useEffect(() => {
    referencia.current = valor;   // se escribe DESPUÉS del render (05-03)
  }, [valor]);

  return referencia.current;      // durante el render, aún contiene el valor anterior
}
// src/componentes/ContadorPlazas.jsx
import { useValorPrevio } from '../hooks/useValorPrevio.js';
import estilos from './ContadorPlazas.module.css';
import { clases } from '../utilidades/clases.js';

function ContadorPlazas({ estacion, plazasLibres }) {
  const plazasPrevias = useValorPrevio(plazasLibres);

  const tendencia =
    plazasPrevias === undefined || plazasPrevias === plazasLibres
      ? 'estable'
      : plazasLibres > plazasPrevias
        ? 'subiendo'
        : 'bajando';

  const SIMBOLOS = { subiendo: '▲', bajando: '▼', estable: '=' };

  return (
    <p className={clases(estilos.contador, estilos[tendencia])}>
      {estacion.nombre}: {plazasLibres} plazas
      <span aria-hidden="true"> {SIMBOLOS[tendencia]}</span>
      <span className={estilos.oculto}>
        {tendencia === 'subiendo' ? 'en aumento' : tendencia === 'bajando' ? 'en descenso' : 'sin cambios'}
      </span>
    </p>
  );
}

Por qué useRef y no useState: escribir el valor previo no debe provocar un render. Con useState, cada actualización del valor dispararía un render extra para guardar la copia, ese render volvería a comparar, y en el mejor de los casos duplicarías los repintados; en el peor, entrarías en bucle. Y hay una razón más profunda: el valor previo no es un dato nuevo que la aplicación produzca, sino memoria de lo que ya se pintó. Encaja exactamente en la definición de useRef de 05-03: algo que se recuerda entre renders sin formar parte de ninguno.

El aria-hidden sobre el símbolo y el texto alternativo oculto vienen de 03-06 y de la convención de EtiquetaEstado: un carácter como no significa nada para un lector de pantalla.

Conclusión

Un hook personalizado es simplemente una función que empieza por use y llama a otros hooks, y con eso se cierra el círculo que 04-04 abrió: la reutilización de lógica con estado sin un solo envoltorio en el árbol de componentes. La propiedad que hay que tener siempre presente es que comparte lógica, no estado: cada componente que llama a useContador, useAlternar o useTemporizadorDisponibilidad obtiene su propia copia independiente, y compartir de verdad un dato sigue siendo trabajo de elevar el estado o del contexto. Has aprendido a extraer un hook paso a paso a partir de código ya escrito, y has construido la colección de CicloUrbano en src/hooks/: useAlternar, useAlmacenLocal con serialización segura, useDebounce para el buscador, useFetchBicicletas con AbortController y cancelación, useEventoTeclado para cerrar el Modal con Escape, useAnchoVentana y useEstadoConexion. Y con ellos, los criterios de diseño que hacen que un hook sea usable por otra persona: array para pares posicionales y objeto para tres o más valores, parámetros con valores por defecto, valores de retorno estables y una sola responsabilidad por hook. Las dos reglas de 04-04 siguen intactas, y a cambio los hooks se componen entre sí igual que los componentes.

Con esto termina el Módulo 5 y, con él, el núcleo de React. En seis lecciones has pasado del useState básico al control completo del estado: la instantánea del render y las actualizaciones en cola (05-01), la sincronización con sistemas externos y las condiciones de carrera (05-02), la memoria que no repinta y el acceso al DOM (05-03), el fin de la perforación de props (05-04), el estado complejo gobernado por acciones y reductores puros (05-05) y tu propia lógica convertida en hooks reutilizables (05-06). CicloUrbano ya es una aplicación con catálogo, filtros, reservas validadas, avisos, tema visual, usuario y carga de datos.

Le falta algo evidente: es una sola pantalla. La cabecera lleva desde el módulo 2 con un menú «Catálogo · Estaciones · Mis reservas» cuyos enlaces no llevan a ninguna parte, porque hasta ahora no había forma de que la URL del navegador y la interfaz se correspondieran. Eso significa direcciones que se pueden compartir, botones de atrás y adelante que funcionan, y secciones que se cargan solo cuando hacen falta. El Módulo 6: Enrutamiento en React lo resuelve con React Router: qué es y por qué no viene incluido en React, cómo se configura, rutas anidadas para que Diseno envuelva a todas las pantallas, navegación programática tras confirmar una reserva y rutas protegidas que solo vea el operario usr-02. La próxima lección es Introducción a React Router.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados