In modern React architecture, custom hooks serve as the primary mechanism for decomposing and reusing logic: synchronizing with external storage (Local Storage, IndexedDB), orchestrating state machines, debouncing/throttling, subscribing to web sockets, and integrating with remote server data.
The React Rules of Hooks prohibit invoking hooks outside functional components or other hooks. Prior to dedicated testing utilities, developers were forced to create synthetic dummy components (TestHostComponent), invoke the target hook inside, propagate return values via props or closures, and mount markup using render(). This introduced substantial boilerplate and cluttered test suites.
renderHook() standardizes custom hook testing. It isolates business logic from visual presentation (UI), guaranteeing strict adherence to the React Fiber lifecycle (mounting, props updates, and effect cleanups).
Central to this workflow is respecting act() boundaries. In React 19, the Concurrent React scheduler aggregates updates into microtasks. Understanding when act() is managed automatically by RTL versus when explicit synchronization is required is critical for eliminating «An update was not wrapped in act(...)» warnings.
Under the hood, renderHook() dynamically constructs an invisible functional component, renders it through standard render(), and subscribes to each update cycle. The hook's return value is captured in a wrapper object result, where result.current reflects the most up-to-date value emitted on the latest render tick.
+-----------------------------------------------------------------------+
| renderHook((props) => useCounter(props), options) |
+-----------------------------------------------------------------------+
│
▼ (Creates synthetic host)
+-----------------------------------------------------------------------+
| Synthetic Host Component <TestComponent {...initialProps} /> |
| - Optional Context Wrapper (QueryClient, AuthContext) |
+-----------------------------------------------------------------------+
│
▼ (React Fiber render phase)
+-----------------------------------------------------------------------+
| Hook call: const data = useCounter(props); |
| result.current = data; |
+-----------------------------------------------------------------------+
│
┌─────────────────┴─────────────────┐
▼ ▼
+-----------------------------------+ +---------------------------------+
| rerender({ step: 5 }) | | unmount() |
| - Re-renders with updated props | | - Unmounts host component |
| - Refreshes result.current | | - Triggers useEffect cleanups |
+-----------------------------------+ +---------------------------------+renderHook Return Structure#result.current: The value (primitive, tuple, or object) returned by the custom hook. Access must occur directly at evaluation time to prevent capturing stale closures.rerender(newProps?): Triggers a synchronous re-render of the host component, forwarding updated arguments to the hook.unmount(): Unmounts the host component, initiating the cleanup phase for all active useEffect and useLayoutEffect handlers.renderHook Signature#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() Required vs. Redundant?#In roughly 95% of RTL test scenarios, explicit act() calls are not required:
userEvent.* (click, type, tab) are wrapped in act() internally.waitFor(() => expect(...)) utility wraps each polling assertion inside act() automatically.renderHook() and rerender() execute within act() by default.Explicit act() is necessary ONLY when:
The hook state mutates due to an external synchronous or asynchronous event triggered directly without userEvent or waitFor (for example, invoking an exported action result.current.increment() directly, or advancing mock timers / triggering raw socket events).
// ✅ Direct invocation of an exported hook function wrapped in act()
act(() => {
result.current.increment();
});useDebounce) with rerender and Fake Timers#Validating debounce hooks requires confirming that output remains stable before the timer expires, updates once the timeout elapses, and cancels preceding timers upon subsequent rapid input changes.
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('delays updating the value by the specified duration', () => {
interface HookProps {
readonly value: string;
readonly delay: number;
}
const { result, rerender } = renderHook(
({ value, delay }: HookProps) => useDebounce(value, delay),
{
initialProps: { value: 'initial', delay: 300 },
}
);
// Initial state matches starting value synchronously
expect(result.current).toBe('initial');
// Pass a new value via rerender
rerender({ value: 'updated-1', delay: 300 });
// Before timer expires, the debounced value must NOT change
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Update rapidly again (verifying previous timer cancellation)
rerender({ value: 'updated-2', delay: 300 });
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('initial');
// Advance past the final timer threshold
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('updated-2');
});
});useLocalStorage)#The hook must persist state into window.localStorage, react to external storage events across browser tabs, and cleanly purge event listeners upon unmounting.
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('reads initial value and updates localStorage on setter invocation', () => {
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('synchronizes state when receiving an external 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');
// Verify listener cleanup on unmount
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function));
});
});wrapper)#Custom hooks frequently access authentication, theming, or query cache contexts. The wrapper option wraps the synthetic host component in real or mocked context providers.
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('throws an error if the hook is invoked outside a provider', () => {
// Suppress React console error output for expected exception
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useUserPermissions())).toThrow(
'useUserPermissions must be used within an AuthProvider'
);
spy.mockRestore();
});
it('returns administrative privileges when user is authenticated as admin', () => {
const mockAuthValue: AuthContextType = {
user: { id: 'usr_77', role: 'admin' },
isAuthenticated: true,
};
// Provider wrapper component
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#For hooks performing asynchronous side effects, data fetching, or timer subscriptions, waitFor continuously retries assertions until they resolve without error.
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('toggles isLoading and saves resolved data', async () => {
const mockFetcher = () => Promise.resolve({ serverStatus: 'HEALTHY_2026' });
const { result } = renderHook(() => useAsyncData(mockFetcher));
// Initial state: loading is active
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeNull();
// Await promise resolution asynchronously
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual({ serverStatus: 'HEALTHY_2026' });
expect(result.current.error).toBeNull();
});
});Destructuring properties directly from result.current immediately after invoking renderHook captures primitives in local variables. Subsequent hook state mutations update result.current, but local destructured variables remain frozen at initial values.
// ❌ ANTIPATTERN: Early destructuring locks in initial stale state
const { result } = renderHook(() => useCounter());
const { count, increment } = result.current;
act(() => increment());
expect(count).toBe(1); // FAILS: count is still 0!
// ✅ SOLUTION: Always access values directly through result.current
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);Writing await act(async () => { await waitFor(...) }) introduces duplicated scheduler scopes because waitFor already wraps its iterations in act(). This can cause subtle microtask timing anomalies and console warnings.
// ❌ ANTIPATTERN: Redundant act() wrapping around waitFor
await act(async () => {
await waitFor(() => expect(result.current.ready).toBe(true));
});
// ✅ SOLUTION: Invoke waitFor directly at top level
await waitFor(() => expect(result.current.ready).toBe(true));When a hook establishes timers, attaches window listeners, or opens WebSocket connections, failing to call unmount() in your test prevents verifying that effect cleanup callbacks (return () => clearInterval(...)) execute correctly.
// ❌ ANTIPATTERN: Test finishes without verifying listener detachment
const { result } = renderHook(() => useWindowScroll());
// ✅ SOLUTION: Explicit unmount() call verifying cleanup execution
const { result, unmount } = renderHook(() => useWindowScroll());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));Calling renderHook() again mounts a completely distinct, isolated component tree with re-initialized state. To update arguments for an existing hook instance, use rerender().
// ❌ ANTIPATTERN: Spawns two independent hook instances
renderHook(() => useMultiplier(2));
renderHook(() => useMultiplier(4)); // State from first instance is destroyed
// ✅ SOLUTION: Use initialProps and rerender()
const { result, rerender } = renderHook(
(val) => useMultiplier(val),
{ initialProps: 2 }
);
rerender(4);Configure initialProps and construct context provider wrappers if dependencies exist.
Call renderHook() to mount the synthetic invisible host component into virtual DOM.
Synchronously evaluate starting values via result.current without destructuring.
Invoke mutating functions inside act() or pass updated inputs via rerender(newProps).
Await async updates with waitFor() and verify cleanup teardown on unmount().