Simple and complete React DOM testing utilities that encourage good testing practices
—
Test custom hooks in isolation without creating wrapper components.
function renderHook<Result, Props>(
callback: (props: Props) => Result,
options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;
interface RenderHookOptions<Props> {
/**
* Initial props passed to the hook callback
*/
initialProps?: Props;
/**
* Custom container element (same as render options)
*/
container?: HTMLElement;
/**
* Base element for queries (same as render options)
*/
baseElement?: HTMLElement;
/**
* Use hydration instead of normal render
*/
hydrate?: boolean;
/**
* Force synchronous rendering.
* Only supported in React 18. Not supported in React 19+.
* Throws an error if used with React 19 or later.
*/
legacyRoot?: boolean;
/**
* Wrapper component for providers
*/
wrapper?: React.JSXElementConstructor<{ children: React.ReactNode }>;
/**
* Enable React.StrictMode wrapper
*/
reactStrictMode?: boolean;
/**
* React 19+ only: Callback when React catches an error in an Error Boundary.
* Only available in React 19 and later.
*/
onCaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void;
/**
* Callback when React automatically recovers from errors.
* Available in React 18 and later.
*/
onRecoverableError?: (error: Error, errorInfo: { componentStack?: string }) => void;
}
interface RenderHookResult<Result, Props> {
/**
* Stable reference to the latest hook return value.
* Access the current value via result.current
*/
result: {
current: Result;
};
/**
* Re-renders the hook with new props
* @param props - New props to pass to the hook callback
*/
rerender: (props?: Props) => void;
/**
* Unmounts the test component, triggering cleanup effects
*/
unmount: () => void;
}function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});function useGreeting(name: string) {
return `Hello, ${name}!`;
}
test('useGreeting with different names', () => {
const { result, rerender } = renderHook(
({ name }) => useGreeting(name),
{ initialProps: { name: 'Alice' } }
);
expect(result.current).toBe('Hello, Alice!');
rerender({ name: 'Bob' });
expect(result.current).toBe('Hello, Bob!');
});function useUser() {
return useContext(UserContext);
}
test('useUser returns context value', () => {
const wrapper = ({ children }) => (
<UserContext.Provider value={{ name: 'John' }}>
{children}
</UserContext.Provider>
);
const { result } = renderHook(() => useUser(), { wrapper });
expect(result.current).toEqual({ name: 'John' });
});function useData(url: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [url]);
return { data, loading };
}
test('useData fetches', async () => {
const { result } = renderHook(() => useData('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(v => !v);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
return { value, toggle, setTrue, setFalse };
}
test('useToggle manages boolean state', () => {
const { result } = renderHook(() => useToggle());
expect(result.current.value).toBe(false);
act(() => result.current.toggle());
expect(result.current.value).toBe(true);
act(() => result.current.setFalse());
expect(result.current.value).toBe(false);
});function useEventListener(event: string, handler: () => void) {
useEffect(() => {
window.addEventListener(event, handler);
return () => window.removeEventListener(event, handler);
}, [event, handler]);
}
test('useEventListener cleans up', () => {
const handler = jest.fn();
const { unmount } = renderHook(() => useEventListener('click', handler));
window.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledTimes(1);
unmount();
window.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledTimes(1); // Not called after unmount
});function useDebounce(value: string, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
test('useDebounce delays updates', async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
);
expect(result.current).toBe('initial');
rerender({ value: 'updated', delay: 500 });
expect(result.current).toBe('initial'); // Not yet updated
await waitFor(() => {
expect(result.current).toBe('updated');
}, { timeout: 1000 });
});function useList<T>(initial: T[] = []) {
const [items, setItems] = useState(initial);
return {
items,
add: useCallback((item: T) => setItems(prev => [...prev, item]), []),
remove: useCallback((index: number) =>
setItems(prev => prev.filter((_, i) => i !== index)), []
),
clear: useCallback(() => setItems([]), []),
};
}
test('useList manages array', () => {
const { result } = renderHook(() => useList(['a']));
expect(result.current.items).toEqual(['a']);
act(() => result.current.add('b'));
expect(result.current.items).toEqual(['a', 'b']);
act(() => result.current.remove(0));
expect(result.current.items).toEqual(['b']);
act(() => result.current.clear());
expect(result.current.items).toEqual([]);
});function useStore() {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(setState);
return unsubscribe;
}, []);
return state;
}
test('useStore syncs with store', () => {
const { result } = renderHook(() => useStore());
expect(result.current.count).toBe(0);
act(() => store.dispatch({ type: 'INCREMENT' }));
expect(result.current.count).toBe(1);
});Wrap state updates in act() to ensure React processes them:
// ✅ Correct
act(() => result.current.action());
// ❌ Wrong - may have timing issues
result.current.action();The result object is stable, but result.current updates with each render:
const { result } = renderHook(() => useState(0));
// ❌ Wrong - stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count); // Still 0
// ✅ Correct - always use result.current
act(() => result.current[1](1));
console.log(result.current[0]); // 1Use waitFor for async hook updates:
const { result } = renderHook(() => useAsync());
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});// test-utils.tsx
import { renderHook, RenderHookOptions } from '@testing-library/react';
export function renderHookWithProviders<Result, Props>(
callback: (props: Props) => Result,
options?: RenderHookOptions<Props>
) {
return renderHook(callback, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
),
...options,
});
}import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
test('useUserQuery fetches user', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const { result } = renderHook(() => useUserQuery('123'), { wrapper });
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual({ id: '123', name: 'John' });
});function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const handleChange = (name, value) => {
setValues(prev => ({ ...prev, [name]: value }));
setErrors(prev => ({ ...prev, [name]: undefined }));
};
const validate = () => {
const newErrors = {};
if (!values.email) newErrors.email = 'Required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
return { values, errors, handleChange, validate };
}
test('useForm validates', () => {
const { result } = renderHook(() => useForm({ email: '' }));
expect(result.current.errors).toEqual({});
act(() => {
const isValid = result.current.validate();
expect(isValid).toBe(false);
});
expect(result.current.errors).toEqual({ email: 'Required' });
act(() => result.current.handleChange('email', 'test@example.com'));
expect(result.current.errors).toEqual({});
act(() => {
const isValid = result.current.validate();
expect(isValid).toBe(true);
});
});State updates in hooks must be wrapped in act() to ensure React processes updates properly:
import { renderHook, act } from '@testing-library/react';
const { result } = renderHook(() => useCounter());
// Wrap state updates in act()
act(() => {
result.current.increment();
});The result object itself is stable across renders, but result.current is updated with each render. Always access the latest value through result.current:
const { result } = renderHook(() => useState(0));
// WRONG: Destructuring creates a stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count); // Still 0 (stale)
// CORRECT: Access via result.current
act(() => result.current[1](1));
console.log(result.current[0]); // 1 (current)Use waitFor for async operations:
import { renderHook, waitFor } from '@testing-library/react';
const { result } = renderHook(() => useAsyncHook());
await waitFor(() => {
expect(result.current.isLoaded).toBe(true);
});The following type aliases are deprecated and provided for backward compatibility:
/** @deprecated Use RenderHookOptions instead */
type BaseRenderHookOptions<Props, Q, Container, BaseElement> = RenderHookOptions<Props>;
/** @deprecated Use RenderHookOptions with hydrate: false instead */
interface ClientRenderHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
hydrate?: false | undefined;
}
/** @deprecated Use RenderHookOptions with hydrate: true instead */
interface HydrateHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
hydrate: true;
}Install with Tessl CLI
npx tessl i tessl/npm-testing-library--react