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
- Qué es un hook personalizado
- Comparte lógica, no estado: la demostración
- Cuándo extraer un hook
- Cómo se extrae, paso a paso
useAlternar: el más simpleuseAlmacenLocal: estado sincronizado con el navegadoruseDebounce: retrasar el término de búsquedauseFetchBicicletas: carga de datos con cancelaciónuseEventoTecladoyuseAnchoVentana- Diseñar la API de un hook
- Las reglas siguen vigentes
useIdy otros hooks de apoyo
- Qué es un hook personalizado
Un hook personalizado es una función de JavaScript cuyo nombre empieza por
usey 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-hooksno puede comprobar que no la llamas dentro de unifo 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/.
- 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í.
- 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
useEffectde 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.
- 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();.
useAlternar: el más simple
useAlternar: el más simpleEmpezamos 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.activarydesactivarusan valor directo porque no dependen del anterior;alternarusa la forma funcional porque sí depende (05-01).useCallbackcon[]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>
);
}
useAlmacenLocal: estado sincronizado con el navegador
useAlmacenLocal: estado sincronizado con el navegadorCombina 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/catchno son paranoia.localStoragelanza 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. claveestá 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: cambiasuseState('claro')poruseAlmacenLocal('ciclourbano:tema', 'claro')y ya está.
// ProveedorTema (05-04) se simplifica así
const [tema, setTema] = useAlmacenLocal('ciclourbano:tema', 'claro');
useDebounce: retrasar el término de búsqueda
useDebounce: retrasar el término de búsquedaBuscadorBicicletas 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.
useFetchBicicletas: carga de datos con cancelación
useFetchBicicletas: carga de datos con cancelaciónEl 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. fasees interna y no se expone. Hacia fuera solo salencargandoyerror, 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.
useEventoTeclado y useAnchoVentana
useEventoTeclado y useAnchoVentanaDos 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 |
- 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 SIEMPRELas 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.
- 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.
useId y otros hooks de apoyo
useId y otros hooks de apoyoUn ú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
usea una función que no llama a ningún hook. Es una función de utilidad: llévala asrc/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.
useCallbackpara 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:
- 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). - 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.
- 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
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
