Key Takeaways
Picture this: You have built a beautiful custom hook that handles complex form validation. It validates emails, checks password strength, and manages error states. You test it manually in your component. It works perfectly. Then a junior developer uses your hook in a different context, forgets to pass a required prop, and your app crashes silently in production.
This is the reality of untested hooks. They work in the context where you built them, but break in unexpected ways when reused. At Boundev, we have seen this pattern destroy applications. Custom hooks are meant to be reusable logic — and reusable logic demands testing. The question is not whether to test hooks, but how to test them correctly.
Why Testing Hooks Separately Matters
Components and hooks have different testing needs. When you test a component, you test how it renders given certain props. When you test a hook, you test the logic it encapsulates — state management, side effects, and the values it returns. Testing them separately gives you confidence that the logic works independently of any component context.
This isolation matters for several reasons. First, hooks encapsulate reusable logic that might be used across multiple components. If that logic is buggy, every component using it is buggy. Second, testing hooks separately makes debugging easier. When a test fails, you know the problem is in the hook logic, not in component rendering. Third, isolated hook tests run faster because they do not render the DOM.
At Boundev, we treat custom hooks as first-class citizens in our testing strategy. Every time a developer creates a custom hook, we require tests that verify the hook behaves correctly with valid inputs, invalid inputs, edge cases, and async operations. This discipline has caught bugs before they reached production dozens of times.
Need React developers who write testable code?
Boundev screens candidates for testing proficiency, not just React syntax. Our engineers write hooks with testability in mind — because we know that untested hooks are ticking time bombs.
Hire React DevelopersSetting Up Your Testing Environment
Before testing hooks, you need the right tools. For React 18 and later, the hook testing utilities are included in @testing-library/react. If you are on an older version, you may need @testing-library/react-hooks as a separate package, but this is now deprecated in favor of the integrated solution.
npm install --save-dev @testing-library/react @testing-library/jest-dom
You also need a test runner. Jest remains the standard for Next.js and Create React App projects, while Vitest has gained significant traction for Vite-based projects due to its faster execution speed. Both work with React Testing Library, though Vitest often provides 10-20x faster feedback on large codebases.
Testing a Simple State Hook
Let us start with the most fundamental hook: useState. The renderHook utility creates an isolated testing environment for your hook. It returns a result object containing the current value of all properties returned by the hook.
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments the count', () => {
const { result } = renderHook(() => useCounter());
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 initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Notice the act() wrapper around state mutations. This is critical. React batches state updates, and act() ensures all updates are processed before your assertions run. Without act(), your tests will be flaky and produce false positives or negatives depending on timing.
Stop Writing Hooks Without Tests
Our React teams write tests alongside every hook we create. We catch bugs in development, not production. Let us show you how we do it.
Talk to Our Engineering TeamTesting Hooks with Side Effects
State hooks are straightforward. Side effects are where testing gets interesting. useEffect runs after every render, and testing it requires careful consideration of what you are actually verifying: that the effect runs when it should, and that cleanup happens when it should.
import { renderHook, act } from '@testing-library/react';
import { useWindowSize } from './useWindowSize';
describe('useWindowSize', () => {
it('updates size on window resize', () => {
const { result } = renderHook(() => useWindowSize());
expect(result.current.width).toBe(1024);
expect(result.current.height).toBe(768);
act(() => {
window.innerWidth = 375;
window.innerHeight = 667;
window.dispatchEvent(new Event('resize'));
});
expect(result.current.width).toBe(375);
expect(result.current.height).toBe(667);
});
it('cleans up event listener on unmount', () => {
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useWindowSize());
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});
});
Testing useEffect cleanup is equally important. When a hook unmounts, any event listeners, timers, or subscriptions should be cleaned up. If they are not, you have memory leaks and potential bugs. Testing unmount behavior ensures your cleanup logic works correctly.
Testing Async Hooks
Async hooks are common: data fetching, polling, debouncing. Testing them requires handling asynchronous state updates. React Testing Library provides waitFor and act() for this purpose. For data fetching hooks specifically, MSW (Mock Service Worker) has become the standard for mocking API calls without modifying your application code.
import { renderHook, waitFor } from '@testing-library/react';
import { useUserProfile } from './useUserProfile';
describe('useUserProfile', () => {
it('fetches and returns user data', async () => {
const { result } = renderHook(() => useUserProfile('user-123'));
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual({
id: 'user-123',
name: 'Sarah Chen',
email: 'sarah@example.com'
});
});
it('handles fetch errors', async () => {
const { result } = renderHook(() => useUserProfile('invalid-user'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeTruthy();
expect(result.current.data).toBeNull();
});
});
The key pattern here is waiting for loading states to change. When testing async hooks, you must wait for React to process state updates before making assertions. The waitFor utility handles this by polling until the expectation passes or a timeout occurs.
Testing Hooks with Context
Some hooks consume context providers. Testing these hooks requires wrapping them in the appropriate providers. The renderHook function accepts a wrapper option for this purpose, allowing you to provide the context your hook needs.
import { renderHook } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { useTheme } from './useTheme';
const createWrapper = (theme = 'light') => {
return ({ children }) => (
<ThemeProvider initialTheme={theme}>
{children}
</ThemeProvider>
);
};
describe('useTheme', () => {
it('uses default theme from provider', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper('dark')
});
expect(result.current.theme).toBe('dark');
});
it('toggles theme correctly', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper('light')
});
expect(result.current.theme).toBe('light');
result.current.toggleTheme();
expect(result.current.theme).toBe('dark');
});
});
Testing Hook Dependencies and Edge Cases
Good hook tests cover more than happy paths. You need to test what happens when dependencies change, when invalid inputs are provided, and when hooks are used in unexpected ways. This comprehensive testing ensures hooks behave predictably in all scenarios.
Essential Edge Cases to Test
Invalid or missing props
What happens when required parameters are undefined or invalid?
Rapid state changes
How does the hook handle multiple updates in quick succession?
Cleanup during operation
What if the component unmounts mid-operation?
Dependency array changes
How does useEffect respond to dependency changes?
How Boundev Solves This for You
Writing testable React hooks and comprehensive test suites is a skill that takes time to develop. At Boundev, our engineers have written thousands of hook tests across dozens of projects. We know what separates tests that catch bugs from tests that give false confidence.
Need React developers who write testable hooks? We pre-screen candidates for testing proficiency, including hook testing patterns with renderHook and React Testing Library.
Building a React codebase with testing standards? Our dedicated teams establish testing practices from day one, including comprehensive hook testing strategies.
Need React development with comprehensive testing? We deliver projects where every custom hook comes with a complete test suite, documented behavior, and edge case coverage.
Ship React code with confidence
Our teams have delivered 200+ React projects with comprehensive testing. Every hook we build comes with tests — because untested hooks are bugs waiting to happen.
Start Testing Your HooksFrequently Asked Questions
What is the difference between renderHook and testing hooks in components?
renderHook isolates the hook from component rendering, letting you test the hook logic directly without DOM overhead. This is faster and more focused. Testing hooks within components is useful for integration testing but adds complexity when you only need to verify hook behavior.
When should I use act() in hook tests?
Wrap every state mutation triggered by your hook in act(). This includes calling functions returned by the hook that update state, triggering events that cause state changes, and manually updating hook state. Without act(), React will warn about updates not wrapped in act(), and your assertions may run before state updates complete.
How do I test hooks that use timers like setTimeout?
Use Jest or Vitest fake timers. Call jest.useFakeTimers() before rendering the hook, then use act(() => { jest.runAllTimers(); }) to execute all pending timers synchronously. This makes tests deterministic instead of waiting for actual time to pass.
Should I test every custom hook?
Yes, for any hook that contains non-trivial logic. If a hook manages state, has side effects, or is reused across multiple components, it should have tests. Simple one-liner wrappers around useState might not need separate tests, but complex state machines, data fetching hooks, and hooks with useEffect definitely do.
What coverage percentage should I aim for with hook tests?
Coverage metrics are less important than meaningful tests. Aim for 100% coverage on hook logic paths, including error handling and edge cases. However, focus on testing behavior (what the hook does) rather than implementation details. Tests that break on refactoring provide no value.
Explore Boundev's Services
Ready to improve your React testing practices? Here is how we can help.
Start Testing Your React Hooks Today
You now know how to test React hooks correctly. The next step is applying these patterns to your codebase — or working with a team that already does.
200+ companies have trusted us to build React applications with comprehensive testing. Tell us what you need — we will respond within 24 hours.
