Why test custom hooks at all?
Custom hooks encapsulate complex logic that often spans multiple components. When that logic breaks, it breaks everywhere it's used. Testing custom hooks directly gives you confidence that the logic works before it ever reaches a component, and it catches bugs faster than waiting for integration tests to fail.
In this guide, we'll walk through testing custom hooks: a practical React & Next.js guide using renderHook, the testing utilities from @testing-library/react, and patterns that scale from simple data-fetching hooks to complex state machines.
Test hooks with complex logic
Let's start with a hook that encapsulates real business logic. Consider a hook that manages a shopping cart: it needs to add items, remove items, calculate totals, handle quantity changes, and persist to localStorage.
// useCart.ts
import { useState, useCallback, useEffect } from 'react';
interface CartItem {
id: string;
price: number;
quantity: number;
}
export function useCart() {
const [items, setItems] = useState<CartItem[]>([]);
const [loaded, setLoaded] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem('cart');
if (saved) {
try {
setItems(JSON.parse(saved));
} catch (e) {
console.error('Failed to parse cart', e);
}
}
setLoaded(true);
}, []);
// Persist whenever items change
useEffect(() => {
if (loaded) {
localStorage.setItem('cart', JSON.stringify(items));
}
}, [items, loaded]);
const addItem = useCallback((id: string, price: number) => {
setItems((prev) => {
const existing = prev.find((item) => item.id === id);
if (existing) {
return prev.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prev, { id, price, quantity: 1 }];
});
}, []);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const getTotal = () => {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
return { items, addItem, removeItem, getTotal, loaded };
}Testing this hook directly means you don't need to mount a component tree. You test the hook in isolation:
// useCart.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCart } from './useCart';
describe('useCart', () => {
beforeEach(() => {
localStorage.clear();
});
it('should add an item to the cart', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('item-1', 29.99);
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0]).toEqual({
id: 'item-1',
price: 29.99,
quantity: 1,
});
});
it('should increment quantity if item already exists', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('item-1', 29.99);
result.current.addItem('item-1', 29.99);
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].quantity).toBe(2);
});
it('should calculate the total correctly', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('item-1', 10);
result.current.addItem('item-2', 20);
result.current.addItem('item-1', 10);
});
expect(result.current.getTotal()).toBe(40); // 10*2 + 20
});
it('should persist to localStorage', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.addItem('item-1', 29.99);
});
expect(localStorage.getItem('cart')).toBe(
JSON.stringify([{ id: 'item-1', price: 29.99, quantity: 1 }])
);
});
});Notice that we call all state updates inside act(). This tells React Testing Library that we're intentionally triggering state changes, and it ensures React flushes all updates before we make assertions. Skipping act() leads to stale assertions and unpredictable test behavior.
Wrap with providers when needed
Many hooks depend on context. A hook for theme management needs a ThemeProvider. A hook for authentication needs an AuthProvider. If your hook calls useContext, your test will fail unless that context is available.
Here's a hook that depends on an auth context:
// useAuthUser.ts
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuthUser() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuthUser must be used within AuthProvider');
}
return context.user;
}Testing this requires wrapping the hook in the provider:
// useAuthUser.test.ts
import { renderHook } from '@testing-library/react';
import { useAuthUser } from './useAuthUser';
import { AuthProvider } from './AuthContext';
describe('useAuthUser', () => {
it('should return the current user', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider initialUser={{ id: '123', email: 'test@example.com' }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuthUser(), { wrapper });
expect(result.current.id).toBe('123');
expect(result.current.email).toBe('test@example.com');
});
it('should throw if used outside AuthProvider', () => {
const { result } = renderHook(() => useAuthUser());
expect(result.error).toEqual(
Error('useAuthUser must be used within AuthProvider')
);
});
});The wrapper prop tells renderHook how to wrap the hook call. Pass any providers, Redux stores, or other setup needed. This is how you compose real-world test environments without mounting entire page trees.
Use act for state updates
State updates in React are asynchronous. If you call result.current.doSomething() and immediately check result.current.value, you might be reading stale state. act() ensures that all pending state updates and effect cleanup have finished before the assertion runs.
Consider a hook that fetches data:
// useFetchUser.ts
import { useState, useEffect } from 'react';
export function useFetchUser(userId: string) {
const [user, setUser] = useState<{ id: string; name: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setError(null);
})
.catch((err) => {
setError(err.message);
setUser(null);
})
.finally(() => setLoading(false));
}, [userId]);
return { user, error, loading };
}The test must wait for the fetch to complete. Here's the right way using waitFor:
// useFetchUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useFetchUser } from './useFetchUser';
describe('useFetchUser', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it('should load user data', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
json: async () => ({ id: '123', name: 'Alice' }),
});
const { result } = renderHook(() => useFetchUser('123'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({ id: '123', name: 'Alice' });
expect(result.current.error).toBeNull();
});
it('should handle fetch errors', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
const { result } = renderHook(() => useFetchUser('123'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.user).toBeNull();
});
});waitFor polls the callback until it stops throwing, or until timeout. It's the right tool for async state updates. Never use setTimeout or random delays in tests—they're brittle and slow.
Prefer testing through components when simple
Not every hook needs a unit test. If a hook is thin and only calls other hooks or returns data directly, testing it through a component might be simpler and more realistic.
Consider this hook:
export function useLocalStorage(key: string) {
const [value, setValue] = useState(() => {
return localStorage.getItem(key) || '';
});
const setStorageValue = (newValue: string) => {
setValue(newValue);
localStorage.setItem(key, newValue);
};
return [value, setStorageValue] as const;
}This is simple enough that testing it in a component feels natural:
// LocalStorageTest.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useLocalStorage } from './useLocalStorage';
function TestComponent() {
const [value, setValue] = useLocalStorage('test-key');
return (
<>
<p>Value: {value}</p>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
aria-label="test-input"
/>
</>
);
}
it('should sync input with localStorage', async () => {
const user = userEvent.setup();
render(<TestComponent />);
const input = screen.getByLabelText('test-input');
await user.type(input, 'hello');
expect(localStorage.getItem('test-key')).toBe('hello');
});This tests the hook in its natural habitat—a component. For simple hooks, this is often clearer and faster to write than renderHook. Save renderHook for complex logic that deserves focused unit tests.
Common mistakes
- Forgetting act() wrapping: State updates outside
act()trigger warnings and cause stale assertions. Always wrap state changes and event handlers. - Testing implementation details: If you test that a hook calls localStorage or an API, you're locked to that implementation. Test the behavior (the hook returns the right data) instead of how it gets there.
- Not mocking external dependencies: Fetch calls, timers, and localStorage need to be mocked. Real I/O in tests is slow and flaky.
- Ignoring re-render behavior: Use the
rerenderfunction to test how a hook responds to prop changes. Many bugs hide in dependency arrays. - Over-testing simple hooks: A hook that just combines
useStateanduseEffectwith no logic doesn't need a unit test. Test it through a component or not at all.
Key takeaways
- Use
renderHookfrom@testing-library/reactto test custom hooks in isolation, especially when they contain complex business logic. - Wrap hooks with providers using the
wrapperoption if they depend on context or other setup. - Always use
act()when triggering state updates, andwaitFor()for async operations. - Test behavior, not implementation: verify that your hook returns the correct data, not that it calls a specific API.
- For simple hooks, consider testing them through a component instead of isolating them with
renderHook. - Mock external dependencies like fetch, timers, and storage to keep tests fast and reliable.






Comments
Loading comments…