En el modelo de componentes de React, un custom hook encapsula estado, ciclo de vida y efectos secundarios mediante la composición de primitivas fundamentales de React Fiber. En arquitecturas empresariales de alta concurrencia, los custom hooks establecen la capa de servicios de la interfaz: abstraen APIs del navegador (Web APIs), protocolos de red, sincronización con almacenamiento local y máquinas de estados lejos de la lógica de renderizado.
En React 19 y Next.js App Router modernos, un custom hook se diseña como una interfaz de programación de aplicaciones (API) formal. La ingeniería de hooks robustos exige abandonar las frágiles tuplas posicionales [state, setState] en favor de objetos estructurados de configuración y resultado (Options / Result Pattern), aserciones estrictas de invariantes en tiempo de renderizado y sincronización isomorfa de almacenes externos mediante useSyncExternalStore.
Diseñar custom hooks sin convenciones de contratos formales genera tres fallos sistémicos graves en bases de código a escala:
[data, loading, error, refetch, mutate], insertar una nueva propiedad en medio del array rompe cientos de componentes consumidores. Confundir el orden de dos variables del mismo tipo (por ejemplo, dos booleanos) durante la desestructuración es una causa habitual de regresiones silenciosas en producción.window.innerWidth, localStorage, matchMedia) mediante useState + useEffect impone un renderizado en dos fases: el servidor genera un marcado HTML, el cliente monta con un estado no inicializado y un efecto posterior al montaje fuerza un nuevo renderizado, causando Cumulative Layout Shift (CLS) y advertencias de desincronización de hidratación.La solución estándar de la industria es la Headless Hook Architecture respaldada por useSyncExternalStore.
El contrato arquitectónico de un hook de nivel de producción se fundamenta en tres pilares:
readonly, valores por defecto razonables y uniones discriminadas para parámetros mutuamente excluyentes.status, data), un conjunto de métodos de control con estabilidad referencial (actions) y metadatos de diagnóstico.// ❌ ANTIPATRÓN: Tupla posicional frágil
const [data, loading, error, refetch, mutate] = useResource(id, true, 5000, 'cache-first');
// Riesgos: orden estricto de argumentos, imposibilidad de omitir parámetros intermedios, errores tipográficos
// ✅ CONTRATO DE PRODUCCIÓN: Patrón Options / Result
const { data, status, actions } = useResource({
id,
pollingIntervalMs: 5000,
fetchPolicy: 'cache-first',
enabled: isAuthorized,
});
// Ventajas: autodocumentado, extensibilidad sin breaking changes, desestructuración limpiaComprobación sincrónica de tipos y precondiciones antes de invocar primitivas reactivas. Si el contrato se viola, se lanza un TypeError con un mensaje inequívoco.
Construcción de getServerSnapshot. Se garantiza la ausencia total de accesos a window, document o navigator en entornos Node.js y Edge Runtime.
useSyncExternalStore coteja sincrónicamente getSnapshot con la instantánea del servidor, erradicando discrepancias de marcado y advertencias de hidratación.
Registro del listener del almacén externo. Notificación a React Fiber ante mutaciones mediante notify con cancelación automática al desmontar.
Memoización del bloque actions y formateo perezoso del estado con useDebugValue para inspección en React DevTools.
¿Por qué en la arquitectura industrial de custom hooks la validación de invariantes obligatorios (ej. assertNonNullContext) debe ejecutarse sincrónicamente en el cuerpo del hook durante el render y no postergarse a useEffect?
ResizeObserver, IntersectionObserver y media queries con garantía de liberación de listeners (cero fugas de memoria).useSyncExternalStore sin re-renders en cascada ni parpadeos de hidratación.Al diseñar las opciones de entrada de un hook, declaramos la interfaz con modificadores readonly. Para neutralizar fallos silenciosos en tiempo de ejecución, se implementan predicados de validación de invariantes.
Examinemos la implementación de referencia del hook de media queries useMediaQuery:
import { useSyncExternalStore, useDebugValue, useMemo } from 'react';
export interface UseMediaQueryOptions {
/** Valor de coincidencia por defecto para entornos SSR */
readonly defaultMatches?: boolean;
/** Callback para captura y reporte de errores sintácticos de CSS */
readonly onError?: (error: Error) => void;
}
export interface UseMediaQueryResult {
/** Coincidencia actual del viewport con la media query */
readonly matches: boolean;
/** Cadena normalizada de la CSS media query original */
readonly query: string;
}
/**
* Valida el invariante de corrección sintáctica de la CSS Media Query.
* Previene el paso de undefined, cadenas vacías y fallos silenciosos en runtime.
*/
function assertValidMediaQuery(query: string): void {
if (typeof query !== 'string' || query.trim().length === 0) {
throw new TypeError(
`[useMediaQuery] Violación de invariante: el argumento 'query' debe ser una cadena no vacía. Recibido: ${JSON.stringify(query)}`
);
}
}
export function useMediaQuery(
query: string,
options: UseMediaQueryOptions = {}
): UseMediaQueryResult {
// 1. Verificación del invariante en la fase más temprana de render (Fail-Fast)
assertValidMediaQuery(query);
const { defaultMatches = false, onError } = options;
// 2. Aislamiento de funciones de suscripción y generación de instantáneas
const { subscribe, getSnapshot, getServerSnapshot } = useMemo(() => {
return {
subscribe: (onStoreChange: () => void): (() => void) => {
if (typeof window === 'undefined' || !('matchMedia' in window)) {
return () => {};
}
let mediaQueryList: MediaQueryList;
try {
mediaQueryList = window.matchMedia(query);
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
onError?.(error);
return () => {};
}
mediaQueryList.addEventListener('change', onStoreChange);
return () => {
mediaQueryList.removeEventListener('change', onStoreChange);
};
},
// Un valor booleano primitivo es estable por definición (comparación por valor)
getSnapshot: (): boolean => {
if (typeof window === 'undefined' || !('matchMedia' in window)) {
return defaultMatches;
}
return window.matchMedia(query).matches;
},
// Instantánea determinista en el servidor: previene accesos indebidos al DOM/BOM
getServerSnapshot: (): boolean => defaultMatches,
};
}, [query, defaultMatches, onError]);
// 3. Conexión al mecanismo de sincronización isomorfa de React Fiber
const matches = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
);
// 4. Traza de depuración para React DevTools con formateo perezoso
useDebugValue({ query, matches }, (val) =>
`[${val.query}] => ${val.matches ? 'MATCHED' : 'UNMATCHED'}`
);
return { matches, query };
}Observe con atención: la función getSnapshot para tipos primitivos (boolean, number, string) devuelve un valor evaluado en tiempo de ejecución mediante Object.is(prev, next). Si getSnapshot retornase un nuevo objeto literal sin memoizar, React entraría en un bucle infinito de renderizado (Infinite Loop).
Comprobación sincrónica de tipos y precondiciones de argumentos. Si se rompe el contrato, se genera una excepción antes de montar el árbol JSX.
El generador de la instantánea de servidor devuelve un estado determinista por defecto, impidiendo accesos a window o navigator en Node.js.
useSyncExternalStore concilia la instantánea del servidor con getSnapshot(). La sincronización elimina discrepancias en el marcado de cliente.
Registro del listener de eventos. Al ocurrir un evento del sistema se ejecuta onStoreChange, planificando un renderizado en la cola de Lanes.
Un custom hook monolítico que realiza llamadas a la red, escribe en localStorage, valida formularios y escucha atajos de teclado viola el principio de responsabilidad única (Single Responsibility Principle). El enfoque industrial se basa en una composición jerárquica:
[useKeySequence] (Atómico: escucha KeyboardEvent y acumula el búfer de entrada)
│
▼
[useShortcut] (Composición: mapea la secuencia contra el mapa de comandos)
│
▼
[useCommandPalette] (Headless UI: gestiona visibilidad del diálogo, foco y búsqueda)Cada hook intermedio define y encapsula su propio contrato, permitiendo al hook de nivel superior integrarlos en una interfaz unificada.
Un ingeniero crea el hook useWindowDimensions donde getSnapshot retorna un objeto literal: () => ({ width: window.innerWidth, height: window.innerHeight }). ¿Por qué la aplicación se bloquea o arroja el error 'Maximum update depth exceeded'?
A continuación se presenta un hook industrial de referencia para la monitorización de la conectividad a Internet, la calidad del enlace (Network Information API), el sondeo activo de salud del servidor (Health Check) y la sincronización entre componentes.
El hook es plenamente seguro en entornos SSR, implementa el patrón Singleton Store para desduplicar listeners entre decenas de componentes consumidores y expone una traza de depuración en DevTools.
import { useSyncExternalStore, useDebugValue, useCallback, useMemo } from 'react';
export type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g' | 'unknown';
export interface NetworkSnapshot {
readonly isOnline: boolean;
readonly downlinkMbps: number | null;
readonly rttMs: number | null;
readonly effectiveType: EffectiveConnectionType;
readonly lastChangedAt: number;
}
export interface UseNetworkStatusOptions {
/** URL del endpoint para sondeo activo de disponibilidad del backend */
readonly pingUrl?: string;
/** Tiempo límite de espera para el sondeo de conexión en milisegundos */
readonly pingTimeoutMs?: number;
/** Estado inicial de contingencia para renderizado en servidor (SSR) */
readonly fallbackSnapshot?: Partial<NetworkSnapshot>;
}
export interface UseNetworkStatusActions {
/** Verificación forzada de conectividad real contra el servidor */
readonly checkConnection: () => Promise<boolean>;
}
export interface UseNetworkStatusResult {
readonly status: NetworkSnapshot;
readonly actions: UseNetworkStatusActions;
}
// Instantánea inmutable y determinista para renderizado en servidor (SSR)
const DEFAULT_SERVER_SNAPSHOT: NetworkSnapshot = Object.freeze({
isOnline: true,
downlinkMbps: null,
rttMs: null,
effectiveType: 'unknown',
lastChangedAt: 0,
});
interface NetworkInformationNavigator extends Navigator {
connection?: {
downlink?: number;
rtt?: number;
effectiveType?: EffectiveConnectionType;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
};
}
/**
* Almacén Singleton para el estado de la red.
* Garantiza un conjunto único de listeners globales de ventana para todos los componentes.
*/
class NetworkStore {
private listeners = new Set<() => void>();
private currentSnapshot: NetworkSnapshot;
constructor(fallback?: Partial<NetworkSnapshot>) {
this.currentSnapshot = this.readClientSnapshot(fallback);
if (typeof window !== 'undefined') {
window.addEventListener('online', this.handleNetworkChange);
window.addEventListener('offline', this.handleNetworkChange);
const nav = navigator as NetworkInformationNavigator;
if (nav.connection?.addEventListener) {
nav.connection.addEventListener('change', this.handleNetworkChange);
}
}
}
private readClientSnapshot(fallback?: Partial<NetworkSnapshot>): NetworkSnapshot {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return { ...DEFAULT_SERVER_SNAPSHOT, ...fallback };
}
const nav = navigator as NetworkInformationNavigator;
const connection = nav.connection;
return {
isOnline: navigator.onLine ?? true,
downlinkMbps: typeof connection?.downlink === 'number' ? connection.downlink : null,
rttMs: typeof connection?.rtt === 'number' ? connection.rtt : null,
effectiveType: connection?.effectiveType ?? 'unknown',
lastChangedAt: Date.now(),
};
}
private handleNetworkChange = (): void => {
this.currentSnapshot = this.readClientSnapshot();
this.listeners.forEach((listener) => listener());
};
public subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
public getSnapshot = (): NetworkSnapshot => {
// Retorna una referencia estable al objeto; solo cambia cuando ocurre un evento real
return this.currentSnapshot;
};
}
let globalNetworkStore: NetworkStore | null = null;
function getGlobalNetworkStore(fallback?: Partial<NetworkSnapshot>): NetworkStore {
if (!globalNetworkStore) {
globalNetworkStore = new NetworkStore(fallback);
}
return globalNetworkStore;
}
export function useNetworkStatus(
options: UseNetworkStatusOptions = {}
): UseNetworkStatusResult {
const {
pingUrl = '/api/health',
pingTimeoutMs = 3000,
fallbackSnapshot,
} = options;
const store = useMemo(
() => getGlobalNetworkStore(fallbackSnapshot),
[fallbackSnapshot]
);
// Suscripción al almacén externo inmune a Tearing y Hydration Mismatch
const status = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
() => ({ ...DEFAULT_SERVER_SNAPSHOT, ...fallbackSnapshot })
);
// Método estable de sondeo activo con cancelación automática por timeout
const checkConnection = useCallback(async (): Promise<boolean> => {
if (typeof window === 'undefined') return true;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), pingTimeoutMs);
try {
const response = await fetch(pingUrl, {
method: 'HEAD',
cache: 'no-store',
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
window.clearTimeout(timeoutId);
}
}, [pingUrl, pingTimeoutMs]);
const actions = useMemo<UseNetworkStatusActions>(
() => ({
checkConnection,
}),
[checkConnection]
);
// useDebugValue expone el estado en el inspector de componentes de React DevTools
useDebugValue(status, (s) =>
`Online: ${s.isOnline ? 'YES' : 'NO'} (${s.effectiveType}, RTT: ${s.rttMs ?? 'n/a'}ms)`
);
return { status, actions };
}Guardar información procedente de objetos globales como window o navigator en el estado local mediante useEffect ocasiona que el primer render en el cliente dibuje un árbol desactualizado y el efecto fuerce inmediatamente un re-renderizado. Esto no solo degrada el Time to Interactive (TTI), sino que dispara errores de Hydration Mismatch en Next.js.
// ❌ ANTIPATRÓN: Doble renderizado y fallo de hidratación
export function useWindowWidthBad() {
const [width, setWidth] = useState<number>(0);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
setWidth(window.innerWidth); // Fuerza un re-renderizado tras el montaje inicial
return () => window.removeEventListener('resize', onResize);
}, []);
return width;
}
// ✅ PATRÓN CORRECTO: useSyncExternalStore con instantánea isomorfa síncrona
export function useWindowWidthGood(defaultWidth = 1280) {
return useSyncExternalStore(
(notify) => {
window.addEventListener('resize', notify);
return () => window.removeEventListener('resize', notify);
},
() => window.innerWidth,
() => defaultWidth
);
}Si un custom hook devuelve funciones (como update(), refetch() o reset()) recreadas en cada render sin memoizar, todos los componentes hijos envueltos en React.memo que reciban dichas funciones como props se re-renderizarán continuamente.
// ❌ ANTIPATRÓN: La función se recrea en cada ciclo de render
export function useCounterBad() {
const [count, setCount] = useState(0);
// En cada actualización de count se genera una referencia nueva de increment
const increment = () => setCount((c) => c + 1);
return { count, increment };
}
// ✅ PATRÓN CORRECTO: Acciones con estabilidad referencial vía useCallback
export function useCounterGood() {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount((c) => c + 1);
}, []);
return { count, increment };
}Cuando un hook efectúa peticiones asíncronas en respuesta a cambios en sus props, una respuesta tardía de una petición antigua puede resolverse después de una respuesta rápida de una petición nueva, sobrescribiendo el estado más reciente con datos obsoletos.
// ❌ ANTIPATRÓN: Una petición lenta con userId=1 puede sobrescribir datos frescos de userId=2
export function useUserDataBad(userId: string) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser(userId).then((data) => setUser(data));
}, [userId]);
return user;
}
// ✅ PATRÓN CORRECTO: Cancelación de peticiones obsoletas mediante AbortController
export function useUserDataGood(userId: string) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = (await response.json()) as User;
setUser(data);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
console.error('Fetch failed', err);
}
}
void load();
return () => {
controller.abort();
};
}, [userId]);
return user;
}Intentar invocar hooks dentro de condicionales (if), operadores ternarios o bucles destruye la estructura de lista enlazada de hooks dentro del nodo Fiber de React, provocando la excepción fatal de runtime: Rendered fewer hooks than expected.
// ❌ ANTIPATRÓN: Invocación condicional que corrompe la lista enlazada de Fiber
export function useConditionalLogger(shouldLog: boolean, data: string) {
if (shouldLog) {
useEffect(() => { // ERROR FATAL: ¡distinta longitud de hooks entre renders!
console.log(data);
}, [data]);
}
}
// ✅ PATRÓN CORRECTO: El hook se invoca incondicionalmente y la condición se evalúa dentro
export function useConditionalLoggerGood(shouldLog: boolean, data: string) {
useEffect(() => {
if (!shouldLog) return;
console.log(data);
}, [shouldLog, data]);
}