How to test custom React hooks using renderHook, handling state updates with act(), testing hook return values, and avoiding common pitfalls when hooks depend on context or async operations.
Renders a hook inside a minimal test component, exposing result.current for assertions and rerender for testing prop changes.
State-changing operations in hooks must be wrapped in act() to ensure React processes updates synchronously before assertions are checked.
Provide context via wrapper components in renderHook options, and use waitFor to assert on eventual state from async hooks.
Test hooks directly when they contain shared complex logic. Test through the component when integration behavior matters more than the hook in isolation.
Custom React hooks encapsulate reusable stateful logic, and testing them requires a different approach than testing components. Since hooks cannot be called outside of a React component, the testing-library provides renderHook to execute hooks in a lightweight test wrapper. Understanding how to test hooks is a common intermediate-level interview question.
React hooks must follow the Rules of Hooks -- they can only be called inside function components or other hooks. You cannot simply call useState() in a test file. The renderHook utility from @testing-library/react solves this by rendering your hook inside a minimal test component and exposing the hook's return value.
The renderHook function takes a callback that calls your hook and returns an object with result and rerender:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});result.current always points to the latest return value of the hook. After state updates, result.current reflects the new state.
When your hook triggers state updates (via useState, useReducer, etc.), those updates must be wrapped in act() to ensure React processes them synchronously before assertions:
test('increments the counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});Forgetting act() causes "act" warnings and potentially stale result.current values. This is the most common pitfall when testing hooks.
To test how a hook responds to changing inputs, use the rerender function with new arguments:
test('updates when props change', () => {
const { result, rerender } = renderHook(
({ initialCount }) => useCounter(initialCount),
{ initialProps: { initialCount: 0 } }
);
expect(result.current.count).toBe(0);
rerender({ initialCount: 10 });
expect(result.current.count).toBe(10);
});If your hook consumes a React context, provide the context via a wrapper component:
const wrapper = ({ children }) => (
<ThemeProvider value="dark">{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });Hooks that trigger async operations (data fetching, debounced updates) require waitFor to assert on eventual state:
test('fetches data on mount', async () => {
const { result } = renderHook(() => useFetchUser(1));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.data).toEqual({ name: 'Alice' });
});
});Test hooks directly when they contain complex logic that multiple components share. If a hook is simple and only used in one component, testing through the component's behavior may be sufficient and more realistic. The general rule: test the hook directly when it is the unit you want to verify, test through the component when the integration matters more.
Fun Fact
renderHook was originally a separate package (@testing-library/react-hooks) maintained by a different team. It was merged into @testing-library/react in version 13.1.0, simplifying the ecosystem and eliminating a common source of version mismatch bugs.