Testing Custom React Hooks with renderHook
Learn how to test custom React hooks using renderHook from React Testing Library, covering state, effects, async behavior, and context.
What you'll learn
- ✓Setting up renderHook from React Testing Library
- ✓Testing hooks with state and effects
- ✓Handling async hooks and waitFor
- ✓Providing context and wrappers
- ✓Common pitfalls and best practices
Prerequisites
- •Basic React knowledge
- •Familiarity with custom hooks
- •Basic testing experience with Jest or Vitest
Custom hooks contain reusable logic, and like any logic, they need tests. But hooks cannot run outside of a React component. The renderHook utility from React Testing Library solves this by rendering your hook inside a minimal test component, giving you access to its return values and the ability to trigger updates.
Setup
Install the testing dependencies if you have not already:
npm install -D @testing-library/react @testing-library/jest-dom vitest jsdom
Configure Vitest to use jsdom as the environment:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});
Testing a Simple Hook
Start with a basic useCounter hook:
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
Test it with renderHook:
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts with the initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('defaults to zero', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments the count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements the count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets to the initial value', () => {
const { result } = renderHook(() => useCounter(3));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(5);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(3);
});
});
The result.current property always points to the latest return value. The act wrapper ensures React processes all state updates before you assert.
Testing Hooks with Effects
Hooks that use useEffect run their effects after rendering. Test them the same way, but be aware that effects fire asynchronously in tests.
// useDocumentTitle.js
import { useEffect } from 'react';
export function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
// useDocumentTitle.test.js
import { renderHook } from '@testing-library/react';
import { useDocumentTitle } from './useDocumentTitle';
describe('useDocumentTitle', () => {
it('sets the document title', () => {
renderHook(() => useDocumentTitle('My Page'));
expect(document.title).toBe('My Page');
});
it('updates when the title changes', () => {
const { rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'First' } }
);
expect(document.title).toBe('First');
rerender({ title: 'Second' });
expect(document.title).toBe('Second');
});
});
The rerender function lets you change the props passed to the hook, simulating a parent component re-rendering with new values.
Testing Async Hooks
For hooks that perform async operations, use waitFor to wait for the expected state.
// useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(json => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
beforeEach(() => {
global.fetch = vi.fn();
});
describe('useFetch', () => {
it('fetches data successfully', async () => {
const mockData = { name: 'Alice' };
fetch.mockResolvedValueOnce({
json: () => Promise.resolve(mockData),
});
const { result } = renderHook(() => useFetch('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
it('handles fetch errors', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error.message).toBe('Network error');
});
});
Providing Context
When a hook reads from Context, you need to wrap the test component in the appropriate provider. Use the wrapper option on renderHook.
// useAuth.js
import { useContext, createContext } from 'react';
export const AuthContext = createContext(null);
export function useAuth() {
const auth = useContext(AuthContext);
if (!auth) throw new Error('useAuth must be inside AuthProvider');
return auth;
}
// useAuth.test.js
import { renderHook } from '@testing-library/react';
import { AuthContext, useAuth } from './useAuth';
function createWrapper(user) {
return function Wrapper({ children }) {
return (
<AuthContext.Provider value={{ user, isLoggedIn: !!user }}>
{children}
</AuthContext.Provider>
);
};
}
describe('useAuth', () => {
it('returns the auth context value', () => {
const user = { id: 1, name: 'Alice' };
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(user),
});
expect(result.current.user).toEqual(user);
expect(result.current.isLoggedIn).toBe(true);
});
it('throws without a provider', () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be inside AuthProvider');
});
});
Testing Cleanup
If your hook returns a cleanup function or performs cleanup in a useEffect, you can verify it by unmounting the hook.
it('clears the interval on unmount', () => {
const clearSpy = vi.spyOn(global, 'clearInterval');
const { unmount } = renderHook(() => usePolling('/api/status', 1000));
unmount();
expect(clearSpy).toHaveBeenCalled();
});
Best Practices
- Test behavior, not implementation. Assert on the hook’s return values, not on which React APIs it calls internally.
- Use
actfor synchronous updates andwaitForfor async ones. Mixing them up leads to warnings and flaky tests. - Keep hooks pure when possible. Hooks that depend on global APIs (fetch, localStorage, timers) are easier to test when those APIs are injected or mocked.
- Test edge cases. What happens when the hook receives
undefined? An empty array? A URL that returns a 404? These are the scenarios where bugs hide.
Testing hooks with renderHook is straightforward once you understand the pattern. The utility handles the React rendering lifecycle so you can focus on verifying that your hook logic is correct.
Related articles
- React React Custom Hooks: Patterns for Reusable Logic
Master React custom hooks with practical patterns for data fetching, form handling, debouncing, and more. Learn when and how to extract reusable logic.
- React React Testing Library: Complete Testing Guide
Learn to test React components with React Testing Library. Covers rendering, user events, async testing, mocking, and accessibility-driven queries.
- React React Higher-Order Components vs Hooks: Migration Guide
Compare Higher-Order Components and hooks in React with side-by-side examples, migration strategies, and guidance on when each pattern still makes sense.
- React React Testing Library: Advanced Patterns for Complex UIs
Advanced testing patterns with React Testing Library including custom render wrappers, async flows, portals, context-heavy components, and integration testing.