Custom hooks encapsulate reusable logic in React applications. Testing them properly ensures your hooks work correctly in isolation before using them in components.
Why Test Hooks Separately?
flowchart LR
subgraph Component["Testing via Components"]
A[More setup required]
B[Tests coupled to UI]
C[Harder to test edge cases]
end
subgraph Hook["Testing Hooks Directly"]
D[Minimal setup]
E[Test logic in isolation]
F[Easy to test edge cases]
end
style Component fill:#f59e0b,color:#fff
style Hook fill:#10b981,color:#fff
Testing hooks directly:
- Allows testing logic without UI dependencies
- Makes edge cases easier to test
- Results in faster, more focused tests
Setting Up
Install the testing library:
npm install -D @testing-library/react
The renderHook function is included in @testing-library/react:
import { renderHook } from '@testing-library/react';
Basic Hook Testing
A Simple Counter Hook
// useCounter.ts
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
Testing the Hook
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
Understanding renderHook
The result Object
renderHook returns an object with a result property:
const { result } = renderHook(() => useMyHook());
// Access current return value
result.current; // { value, setValue, ... }
result.current always contains the latest return value from the hook.
The act Function
Wrap state updates in act to ensure React processes them:
import { act } from '@testing-library/react';
test('updates state', () => {
const { result } = renderHook(() => useCounter());
// Without act, this might not update properly
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Testing Hooks with Props
Changing Props
Use rerender to test hooks with different props:
// useTitle.ts
import { useEffect } from 'react';
export function useTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
// useTitle.test.ts
test('updates document title', () => {
const { rerender } = renderHook(({ title }) => useTitle(title), {
initialProps: { title: 'Initial Title' },
});
expect(document.title).toBe('Initial Title');
rerender({ title: 'New Title' });
expect(document.title).toBe('New Title');
});
Testing with Dependencies
// useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// useLocalStorage.test.ts
beforeEach(() => {
localStorage.clear();
});
test('initializes with value from localStorage', () => {
localStorage.setItem('test-key', JSON.stringify('stored value'));
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
expect(result.current[0]).toBe('stored value');
});
test('initializes with default when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
expect(result.current[0]).toBe('default');
});
test('updates localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
result.current[1]('new value');
});
expect(localStorage.getItem('test-key')).toBe('"new value"');
});
test('updates value when key changes', () => {
localStorage.setItem('key-a', JSON.stringify('value-a'));
localStorage.setItem('key-b', JSON.stringify('value-b'));
const { result, rerender } = renderHook(
({ key }) => useLocalStorage(key, 'default'),
{ initialProps: { key: 'key-a' } }
);
expect(result.current[0]).toBe('value-a');
rerender({ key: 'key-b' });
expect(result.current[0]).toBe('value-b');
});
Testing Async Hooks
Async Data Fetching Hook
// useFetch.ts
import { useState, useEffect } from 'react';
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
Testing with MSW
// useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import { useFetch } from './useFetch';
test('fetches data successfully', async () => {
server.use(
http.get('/api/data', () => {
return HttpResponse.json({ message: 'Hello' });
})
);
const { result } = renderHook(() => useFetch('/api/data'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ message: 'Hello' });
expect(result.current.error).toBe(null);
});
test('handles fetch error', async () => {
server.use(
http.get('/api/data', () => {
return HttpResponse.error();
})
);
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toBeTruthy();
});
test('refetches when URL changes', async () => {
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({ id: 1, name: 'Alice' });
}),
http.get('/api/users/2', () => {
return HttpResponse.json({ id: 2, name: 'Bob' });
})
);
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users/1' } }
);
await waitFor(() => {
expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
});
rerender({ url: '/api/users/2' });
await waitFor(() => {
expect(result.current.data).toEqual({ id: 2, name: 'Bob' });
});
});
Testing Hooks with Context
Hook That Uses Context
// useAuth.ts
import { useContext } from 'react';
import { AuthContext } from './AuthContext';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Creating a Wrapper
// useAuth.test.ts
import { renderHook } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import { useAuth } from './useAuth';
test('returns auth context', () => {
const wrapper = ({ children }) => (
<AuthProvider value={{ user: { name: 'John' }, isAuthenticated: true }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toEqual({ name: 'John' });
expect(result.current.isAuthenticated).toBe(true);
});
test('throws error when used outside provider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within an AuthProvider');
consoleSpy.mockRestore();
});
Reusable Wrapper Factory
// test-utils.tsx
import { AuthProvider } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
export function createWrapper(authValue, themeValue) {
return function Wrapper({ children }) {
return (
<AuthProvider value={authValue}>
<ThemeProvider value={themeValue}>
{children}
</ThemeProvider>
</AuthProvider>
);
};
}
// Usage in tests
test('hook with multiple contexts', () => {
const wrapper = createWrapper(
{ user: null, isAuthenticated: false },
{ theme: 'dark' }
);
const { result } = renderHook(() => useMyHook(), { wrapper });
// ...
});
Testing Hooks with Timers
Debounce Hook
// useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Testing with Fake Timers
// useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500));
expect(result.current).toBe('hello');
});
test('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'initial' } }
);
// Change value
rerender({ value: 'changed' });
// Value hasn't changed yet
expect(result.current).toBe('initial');
// Advance timer partially
act(() => {
jest.advanceTimersByTime(300);
});
// Still hasn't changed
expect(result.current).toBe('initial');
// Advance past delay
act(() => {
jest.advanceTimersByTime(200);
});
// Now it has changed
expect(result.current).toBe('changed');
});
test('resets timer on rapid changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'b' });
act(() => jest.advanceTimersByTime(300));
rerender({ value: 'c' });
act(() => jest.advanceTimersByTime(300));
// Still 'a' because timer keeps resetting
expect(result.current).toBe('a');
act(() => jest.advanceTimersByTime(200));
// Now it's 'c' (the last value)
expect(result.current).toBe('c');
});
Testing Hooks with Callbacks
Hook That Accepts Callbacks
// useEventListener.ts
import { useEffect, useRef } from 'react';
export function useEventListener(
eventName: string,
handler: (event: Event) => void,
element: HTMLElement | Window = window
) {
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (event: Event) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
Testing Event Handlers
// useEventListener.test.ts
test('adds event listener', () => {
const handler = jest.fn();
renderHook(() => useEventListener('click', handler));
// Simulate click
act(() => {
window.dispatchEvent(new Event('click'));
});
expect(handler).toHaveBeenCalledTimes(1);
});
test('removes event listener on unmount', () => {
const handler = jest.fn();
const { unmount } = renderHook(() => useEventListener('click', handler));
unmount();
act(() => {
window.dispatchEvent(new Event('click'));
});
expect(handler).not.toHaveBeenCalled();
});
test('uses latest handler', () => {
const handler1 = jest.fn();
const handler2 = jest.fn();
const { rerender } = renderHook(
({ handler }) => useEventListener('click', handler),
{ initialProps: { handler: handler1 } }
);
rerender({ handler: handler2 });
act(() => {
window.dispatchEvent(new Event('click'));
});
expect(handler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
});
Common Patterns
Testing Multiple State Updates
test('handles multiple updates', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(3);
});
Testing Cleanup
test('cleans up on unmount', () => {
const cleanup = jest.fn();
const { unmount } = renderHook(() => {
useEffect(() => cleanup, []);
});
expect(cleanup).not.toHaveBeenCalled();
unmount();
expect(cleanup).toHaveBeenCalledTimes(1);
});
Summary
| Concept | Description |
|---|---|
renderHook() |
Renders a hook for testing |
result.current |
Current return value of the hook |
act() |
Wrap state updates to process them |
rerender() |
Re-render with new props |
unmount() |
Unmount the hook |
wrapper |
Provide context/providers |
Key takeaways:
- Use
renderHookto test hooks in isolation - Wrap state updates in
act()to ensure they're processed - Use
rerenderto test hooks with changing props - Provide a
wrapperfor hooks that use Context - Use fake timers for hooks with delays/debounce
- Test cleanup by checking behavior after
unmount()
Testing custom hooks directly leads to more focused, maintainable tests. You can thoroughly test the hook's logic before integrating it into components.
References
- Testing Library renderHook
- React Hooks Testing
- Crump, Scottie. Simplify Testing with React Testing Library. Packt, 2021.
- Barklund, Morten. React in Depth. Manning Publications, 2024.