Testing React Hooks with Vitest & @testing-library/react
Custom React hooks encapsulate reusable logic. Testing them is different from testing components because hooks can't render standalone—they must run inside a component. The renderHook utility from React Testing Library lets you test hooks directly, isolating their behavior and making it easy to verify state updates, side effects, and cleanup (React Testing Library, 2026). This article teaches you to test hooks with confidence.
When to Test a Hook vs. Test a Component Using It
Test a hook directly with renderHook() when the hook's logic is complex and reusable across multiple components. Test a component that uses a hook to verify integration. Here's the decision tree:
- Simple hook (a wrapper around
useState): Test through a component. - Complex hook (orchestrates multiple state updates, side effects, subscriptions): Test with
renderHook(). - Hook used in one place: Test through the component using it.
- Hook used in many places: Test with
renderHook()to avoid duplication.
Setup: Install React Hooks Testing Utilities
Install @testing-library/react (includes renderHook) and @testing-library/react-hooks for older versions:
npm install -D @testing-library/react @testing-library/jest-dom vitest
In Vitest 0.31+, renderHook is in @testing-library/react directly. No separate package needed.
Basic Hook Test Pattern
Let's test a custom useCounter hook:
// hooks/useCounter.ts
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
Test it with renderHook:
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value of 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements 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(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Key points:
renderHook()returns an object withresult, which containscurrent—the hook's return value.- Wrap state updates in
act()so Vitest knows to wait for React's state update batch. result.currentupdates afteract()completes.
Testing Hooks with useEffect
useEffect side effects are where many bugs hide. Test them explicitly.
// hooks/useFetch.ts
import { useEffect, useState } from 'react';
interface UseFetchResult {
data: unknown;
loading: boolean;
error: Error | null;
}
export function useFetch(url: string): UseFetchResult {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
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; // Cleanup prevents state updates on unmounted hook
};
}, [url]);
return { data, loading, error };
}
Test it by mocking fetch:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('starts in loading state', () => {
vi.stubGlobal('fetch', vi.fn(() =>
new Promise(() => {}) // Never resolves
));
const { result } = renderHook(() => useFetch('/api/user'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
});
it('fetches data and updates state', async () => {
const mockData = { id: 1, name: 'Alice' };
vi.stubGlobal('fetch', vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(mockData)))
));
const { result } = renderHook(() => useFetch('/api/user'));
// Initially loading
expect(result.current.loading).toBe(true);
// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
it('handles fetch errors', async () => {
const mockError = new Error('Network failed');
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(mockError)));
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBe(null);
});
});
Pattern: Mock the external dependency (fetch), render the hook, wait for async updates with waitFor(), then assert the final state.
Testing Hook Dependency Updates
When a hook's dependency array changes, useEffect re-runs. Test this:
it('refetches when URL changes', async () => {
const mockFetch = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify({ data: 'test' })))
);
vi.stubGlobal('fetch', mockFetch);
const { result, rerender } = renderHook(
({ url }: { url: string }) => useFetch(url),
{ initialProps: { url: '/api/user' } }
);
// First fetch
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockFetch).toHaveBeenCalledWith('/api/user');
// Change URL and re-render
rerender({ url: '/api/posts' });
// Wait for second fetch
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith('/api/posts');
});
});
Pattern: Use rerender() to change props, then verify useEffect re-ran with new dependencies.
Testing Hook Cleanup
Cleanup functions prevent memory leaks. Test that they run:
it('cleans up subscriptions on unmount', () => {
const unsubscribe = vi.fn();
const subscribe = vi.fn((callback: () => void) => {
callback();
return unsubscribe;
});
vi.stubGlobal('EventBus', { subscribe });
const { unmount } = renderHook(() => {
const [value, setValue] = useState(null);
useEffect(() => {
return subscribe(() => setValue('data'));
}, []);
return value;
});
expect(subscribe).toHaveBeenCalled();
unmount();
expect(unsubscribe).toHaveBeenCalled();
});
Pattern: Verify cleanup functions are called by checking mocks after unmount().
Testing Custom Context Hooks
Hooks that use useContext need a Context Provider wrapper:
it('reads from context', () => {
const TestProvider = ({ children }: { children: React.ReactNode }) => (
<ThemeContext.Provider value={{ theme: 'dark' }}>
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), {
wrapper: TestProvider,
});
expect(result.current.theme).toBe('dark');
});
Pattern: Use the wrapper option to render the hook inside a Provider.
Key Takeaways
- Use
renderHook()to test complex, reusable hooks directly. - Always wrap state updates in
act()so Vitest waits for them. - Test
useEffectside effects by mocking external dependencies and waiting for async updates withwaitFor(). - Test dependency array changes with
rerender(). - Test cleanup functions by verifying they're called on
unmount(). - Use the
wrapperoption to test hooks that depend on Context providers.
Frequently Asked Questions
Do I always need to use act() around hook state updates?
Yes, when you call a function that updates state (like increment() in the counter example), wrap it in act(). However, renderHook() automatically handles state updates inside component event handlers, so if you're testing a hook through a component, act() is often unnecessary.
How do I test a hook that depends on localStorage?
Mock localStorage with vi.stubGlobal() or vi.mock(). For example:
vi.stubGlobal('localStorage', {
getItem: vi.fn(() => 'stored-value'),
setItem: vi.fn(),
removeItem: vi.fn(),
});
Then render the hook and verify it reads/writes the mocked storage.
Can I test a hook that uses useRef?
Yes. The ref value is available on result.current. For example, if your hook returns a ref, you can access result.current.inputRef.current. However, remember that refs are often implementation details—test observable behavior when possible.
Why does my hook test hang with waitFor?
waitFor() has a default timeout of 1000ms. If your hook never reaches the expected state, it will timeout. Make sure your mock is set up correctly and that you're waiting for the right state change. Use a shorter timeout for debugging: waitFor(() => {...}, { timeout: 100 }).
Should I test every hook with renderHook or test through components?
Test complex, reusable hooks with renderHook() so you have isolated, fast tests. Test hooks that are tightly coupled to one component by testing the component directly. A well-balanced test suite has both.