En la arquitectura de las aplicaciones modernas de React, los hooks personalizados sirven como la herramienta primordial para la descomposición y reutilización de lógica: sincronización con almacenamientos externos (LocalStorage, IndexedDB), gestión de máquinas de estado, debounce/throttling, suscripción a WebSockets e integración con datos de servidor.
Las reglas de los hooks de React (Rules of Hooks) prohíben su invocación fuera de componentes funcionales u otros hooks. Antes de la aparición de utilidades específicas, los desarrolladores debían crear manualmente componentes sintéticos comodín (TestHostComponent), invocar en ellos el hook bajo prueba, propagar los valores devueltos mediante props o closures y montar el marcado a través de render(). Esto generaba código repetitivo (boilerplate) innecesario y oscurecía los tests.
renderHook() estandariza las pruebas de hooks personalizados. Aísla la lógica de negocio de la interfaz de usuario (UI), garantizando el cumplimiento estricto del ciclo de vida de React Fiber (montaje, actualización de props, limpieza de efectos).
En este flujo de trabajo, un concepto esencial es respetar los límites de act(). En React 19, el planificador de Concurrent React agrupa las actualizaciones en microtareas. Comprender cuándo act() es gestionado automáticamente por RTL y cuándo se requiere sincronización explícita resulta determinante para eliminar advertencias del tipo «An update was not wrapped in act(...)».
Internamente, renderHook() construye de forma dinámica un componente funcional invisible, lo renderiza mediante el render() estándar y se suscribe a cada actualización. El resultado de la ejecución del hook se almacena en el objeto contenedor result, donde la propiedad result.current conserva el valor más reciente emitido por el hook en el último ciclo de renderizado.
+-----------------------------------------------------------------------+
| renderHook((props) => useCounter(props), options) |
+-----------------------------------------------------------------------+
│
▼ (Creación del anfitrión sintético)
+-----------------------------------------------------------------------+
| Synthetic Host Component <TestComponent {...initialProps} /> |
| - Context Wrapper opcional (QueryClient, AuthContext) |
+-----------------------------------------------------------------------+
│
▼ (Fase de renderizado React Fiber)
+-----------------------------------------------------------------------+
| Llamada al hook: const data = useCounter(props); |
| result.current = data; |
+-----------------------------------------------------------------------+
│
┌─────────────────┴─────────────────┐
▼ ▼
+-----------------------------------+ +---------------------------------+
| rerender({ step: 5 }) | | unmount() |
| - Renderizado con nuevas props | | - Desmontaje del anfitrión |
| - Actualización de result.current | | - Disparo de cleanups useEffect |
+-----------------------------------+ +---------------------------------+renderHook#result.current: El valor (primitivo, tupla u objeto) devuelto por el hook personalizado. Su acceso debe realizarse directamente en el instante de la aserción para evitar la captura de closures obsoletos (stale closure).rerender(newProps?): Fuerza un re-renderizado síncrono del componente anfitrión, suministrando argumentos actualizados al hook.unmount(): Desmonta el componente anfitrión, iniciando la fase de limpieza de todos los efectos activos useEffect y useLayoutEffect.renderHook#import { renderHook, waitFor, act } from '@testing-library/react';
const { result, rerender, unmount } = renderHook(
(renderCallbackProps) => useMyHook(renderCallbackProps),
{
initialProps: { count: 0 },
wrapper: ({ children }) => <MyProvider>{children}</MyProvider>,
}
);act() y Cuándo es Redundante?#En el 95% de los escenarios con RTL, la invocación explícita de act() no es necesaria:
userEvent.* (click, type, tab) ya están envueltas en act() internamente.waitFor(() => expect(...)) envuelve automáticamente cada iteración de comprobación en act().renderHook() y rerender() se ejecutan de forma predeterminada dentro de act().act() explícito se requiere ÚNICAMENTE cuando:
El estado del hook muta como consecuencia de un evento síncrono o asíncrono externo disparado directamente sin recurrir a userEvent o waitFor (por ejemplo, invocar una función exportada result.current.increment() de forma directa, o avanzar temporizadores simulados/despachar eventos de socket nativos).
// ✅ Invocación directa de la función del hook envuelta en act()
act(() => {
result.current.increment();
});useDebounce) con rerender y Fake Timers#Validar hooks de retardo requiere confirmar que el valor permanece inmutable antes de que expire el temporizador, se actualiza tras cumplirse el lapso y cancela temporizadores anteriores cuando se suceden cambios rápidos.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timer);
};
}, [value, delayMs]);
return debouncedValue;
}
describe('useDebounce Hook', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('retrasa la actualización del valor durante el intervalo de tiempo especificado', () => {
interface HookProps {
readonly value: string;
readonly delay: number;
}
const { result, rerender } = renderHook(
({ value, delay }: HookProps) => useDebounce(value, delay),
{
initialProps: { value: 'initial', delay: 300 },
}
);
// El estado inicial coincide de manera síncrona con el inicial
expect(result.current).toBe('initial');
// Transmitimos un nuevo valor mediante rerender
rerender({ value: 'updated-1', delay: 300 });
// Antes de completarse el tiempo el valor NO debe cambiar
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Cambiamos nuevamente de forma rápida (comprobación de cancelación del temporizador previo)
rerender({ value: 'updated-2', delay: 300 });
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Avanzamos el tiempo hasta concluir el último temporizador
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('updated-2');
});
});useLocalStorage)#El hook debe persistir el valor en window.localStorage, responder a eventos storage procedentes de otras pestañas y liberar adecuadamente los recursos al desmontarse.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T): readonly [T, (val: T | ((prev: T) => T)) => void] {
const readValue = useCallback((): T => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error: unknown) {
console.error(error);
}
},
[key, storedValue]
);
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue) as T);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue] as const;
}
describe('useLocalStorage Hook', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('lee el valor predeterminado y actualiza localStorage al invocar el setter', () => {
const { result } = renderHook(() => useLocalStorage<number>('app_theme_mode', 1));
expect(result.current[0]).toBe(1);
act(() => {
result.current[1](2);
});
expect(result.current[0]).toBe(2);
expect(window.localStorage.getItem('app_theme_mode')).toBe('2');
});
it('se sincroniza al recibir un evento externo StorageEvent', () => {
const { result, unmount } = renderHook(() =>
useLocalStorage<string>('auth_token', 'empty')
);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'auth_token',
newValue: JSON.stringify('jwt_secret_token_2026'),
})
);
});
expect(result.current[0]).toBe('jwt_secret_token_2026');
// Verificación de desvinculación del listener al desmontar
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});
});wrapper)#Los hooks personalizados suelen acceder a contextos de autenticación, tema o caché de peticiones. El parámetro wrapper permite envolver el componente anfitrión en proveedores de contexto reales o simulados.
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import React, { createContext, useContext } from 'react';
interface UserProfile {
readonly id: string;
readonly role: 'admin' | 'viewer';
}
interface AuthContextType {
readonly user: UserProfile | null;
readonly isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
function useUserPermissions() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useUserPermissions must be used within an AuthProvider');
}
const canEdit = context.isAuthenticated && context.user?.role === 'admin';
return { ...context, canEdit };
}
describe('useUserPermissions with Context Wrapper', () => {
it('lanza un error si el hook se invoca fuera de un proveedor', () => {
// Silenciamos el error de consola de React para la excepción esperada
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useUserPermissions())).toThrow(
'useUserPermissions must be used within an AuthProvider'
);
spy.mockRestore();
});
it('devuelve privilegios de administrador cuando el usuario está autenticado como admin', () => {
const mockAuthValue: AuthContextType = {
user: { id: 'usr_77', role: 'admin' },
isAuthenticated: true,
};
// Componente wrapper del proveedor
const wrapper = ({ children }: { readonly children: React.ReactNode }) => (
<AuthContext.Provider value={mockAuthValue}>
{children}
</AuthContext.Provider>
);
const { result } = renderHook(() => useUserPermissions(), { wrapper });
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.canEdit).toBe(true);
expect(result.current.user?.role).toBe('admin');
});
});waitFor#Para hooks que ejecutan operaciones asíncronas, consultas de red o suscripciones temporizadas, se recurre a waitFor. Esta utilidad sondea la aserción periódicamente hasta que se cumple con éxito.
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useState, useEffect } from 'react';
interface AsyncState<T> {
readonly data: T | null;
readonly isLoading: boolean;
readonly error: string | null;
}
function useAsyncData<T>(fetcher: () => Promise<T>): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
isLoading: true,
error: null,
});
useEffect(() => {
let isCancelled = false;
fetcher()
.then((res) => {
if (!isCancelled) {
setState({ data: res, isLoading: false, error: null });
}
})
.catch((err: unknown) => {
if (!isCancelled) {
const message = err instanceof Error ? err.message : 'Unknown error';
setState({ data: null, isLoading: false, error: message });
}
});
return () => {
isCancelled = true;
};
}, [fetcher]);
return state;
}
describe('useAsyncData Hook', () => {
it('conmuta la propiedad isLoading y conserva los datos recuperados', async () => {
const mockFetcher = () => Promise.resolve({ serverStatus: 'HEALTHY_2026' });
const { result } = renderHook(() => useAsyncData(mockFetcher));
// Estado inicial: la carga se encuentra activa
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeNull();
// Espera asíncrona de la resolución de la promesa
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual({ serverStatus: 'HEALTHY_2026' });
expect(result.current.error).toBeNull();
});
});Desestructurar propiedades directamente desde result.current al instanciar renderHook fija los primitivos en variables locales. Las mutaciones posteriores modificarán result.current, pero las variables locales desestructuradas permanecerán estáticas.
// ❌ ANTIPATRÓN: La desestructuración temprana captura un estado obsoleto
const { result } = renderHook(() => useCounter());
const { count, increment } = result.current;
act(() => increment());
expect(count).toBe(1); // ¡FALLO: count sigue siendo 0!
// ✅ SOLUCIÓN: Acceder siempre a través de result.current de forma directa
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);Escribir await act(async () => { await waitFor(...) }) duplica el ámbito del planificador, puesto que waitFor ya ejecuta sus iteraciones dentro de act(). Esto genera desajustes sutiles en la resolución de microtareas y avisos en consola.
// ❌ ANTIPATRÓN: Llamada redundante a act envolviendo waitFor
await act(async () => {
await waitFor(() => expect(result.current.ready).toBe(true));
});
// ✅ SOLUCIÓN: Invocación directa de waitFor en el nivel principal
await waitFor(() => expect(result.current.ready).toBe(true));Si un hook configura temporizadores, añade listeners al objeto window o abre sockets, prescindir de unmount() en el test imposibilita verificar que los destructores de los efectos (return () => clearInterval(...)) se ejecuten.
// ❌ ANTIPATRÓN: El test concluye sin verificar la limpieza de listeners
const { result } = renderHook(() => useWindowScroll());
// ✅ SOLUCIÓN: Desmontaje explícito con unmount() y validación del cleanup
const { result, unmount } = renderHook(() => useWindowScroll());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));Volver a llamar a renderHook monta un árbol de componentes independiente con un estado inicial completamente restablecido. Para transferir nuevos argumentos a una instancia existente, debe utilizarse rerender().
// ❌ ANTIPATRÓN: Crea dos instancias de hooks inconexas
renderHook(() => useMultiplier(2));
renderHook(() => useMultiplier(4)); // El estado del primer hook se pierde
// ✅ SOLUCIÓN: Uso de initialProps y rerender
const { result, rerender } = renderHook(
(val) => useMultiplier(val),
{ initialProps: 2 }
);
rerender(4);Configurar initialProps y definir wrappers de proveedores de contexto según sea necesario.
Invocar renderHook() para montar el componente anfitrión invisible en el DOM virtual.
Evaluar síncronamente los valores de inicio mediante result.current sin desestructurar.
Llamar a funciones mutadoras dentro de act() o enviar nuevas entradas con rerender(newProps).
Esperar cambios con waitFor() y asegurar la ejecución de cleanups al invocar unmount().