Testing Custom Hooks
Why Custom Hooks Need Their Own Tests
You built a useFetch hook that three components rely on. One day, a teammate refactors it. The components still render, but the hook now fires an extra network request on every keystroke. Component tests might not catch that — they test the UI, not the hook's internal behavior.
Custom hooks are reusable units of logic. They deserve their own test suite, separate from the components that consume them. Testing hooks directly lets you verify state transitions, side effects, cleanup behavior, and edge cases without the noise of a full component render.
Think of a custom hook like a function library that happens to use React internals. You wouldn't test a utility function by rendering a component that calls it and then checking what showed up on screen. You'd call the function directly and check the return value. renderHook gives you that same directness for hooks — call the hook, inspect what it returns, trigger updates, check again.
The renderHook API
React hooks can only run inside a component. You can't just call useState() in a test file — React will throw an error. The renderHook utility from @testing-library/react solves this by creating a thin wrapper component behind the scenes and rendering your hook inside it.
import { renderHook } from '@testing-library/react';
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount((c) => c + 1);
const reset = () => setCount(initial);
return { count, increment, reset };
}
test('starts with the initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
renderHook returns an object with a result ref. The result.current property always points to whatever your hook returned on the most recent render. This is a ref, not a snapshot — it updates as the hook re-renders.
The result.current Ref Trap
Here's something that trips people up early:
test('do NOT destructure result.current', () => {
const { result } = renderHook(() => useCounter());
// This captures the value at this moment in time
const { count } = result.current;
act(() => {
result.current.increment();
});
// count is still 0 — it's a stale snapshot!
expect(count).toBe(0);
// result.current.count reflects the latest value
expect(result.current.count).toBe(1);
});
When you destructure result.current, you're copying primitive values at that point in time. After the hook re-renders, result.current points to the new return value, but your destructured variable is stuck on the old one.
Testing Hooks with useState and useEffect
Most custom hooks combine state and effects. Let's test a hook that debounces a value:
function useDebouncedValue<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Testing this requires controlling time. Vitest (and Jest) provide fake timers:
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('returns the initial value immediately', () => {
const { result } = renderHook(() => useDebouncedValue('hello', 300));
expect(result.current).toBe('hello');
});
test('updates the value after the delay', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebouncedValue(value, delay),
{ initialProps: { value: 'hello', delay: 300 } }
);
rerender({ value: 'world', delay: 300 });
// Before delay: still the old value
expect(result.current).toBe('hello');
act(() => {
vi.advanceTimersByTime(300);
});
// After delay: updated
expect(result.current).toBe('world');
});
test('cancels pending update when value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebouncedValue(value, delay),
{ initialProps: { value: 'a', delay: 300 } }
);
rerender({ value: 'b', delay: 300 });
act(() => {
vi.advanceTimersByTime(200);
});
// Change again before the first debounce fires
rerender({ value: 'c', delay: 300 });
act(() => {
vi.advanceTimersByTime(300);
});
// 'b' was never emitted — cleanup cleared the timer
expect(result.current).toBe('c');
});
Notice the rerender function. When your hook depends on props, pass them through initialProps and use rerender with new props to simulate changes. This mirrors what happens when a parent component re-renders with different prop values.
Context Providers and the wrapper Option
Hooks that call useContext need a provider in the component tree. Without one, they'll get the default context value (or throw, if your hook requires a provider). The wrapper option lets you wrap the test component in providers:
const ThemeContext = createContext<'light' | 'dark'>('light');
function useTheme() {
return useContext(ThemeContext);
}
test('returns the theme from context', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');
});
For hooks that depend on multiple providers, compose them:
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<ThemeContext.Provider value="dark">
<AuthContext.Provider value={{ user: { name: 'Test' } }}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthContext.Provider>
</ThemeContext.Provider>
);
}
test('hook with multiple context dependencies', () => {
const { result } = renderHook(() => useAppData(), {
wrapper: AllProviders,
});
expect(result.current.theme).toBe('dark');
expect(result.current.user.name).toBe('Test');
});
A common pattern is to create a reusable createWrapper function that accepts overrides:
function createWrapper(overrides?: { theme?: 'light' | 'dark' }) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeContext.Provider value={overrides?.theme ?? 'light'}>
{children}
</ThemeContext.Provider>
);
};
}
test('works with light theme', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper({ theme: 'light' }),
});
expect(result.current).toBe('light');
});
test('works with dark theme', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: createWrapper({ theme: 'dark' }),
});
expect(result.current).toBe('dark');
});
Testing Async Hooks with waitFor
Hooks that fetch data or depend on async operations need waitFor to assert on values that aren't available immediately:
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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;
};
}, [url]);
return { data, loading, error };
}
import { renderHook, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
test('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test User' };
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
json: () => Promise.resolve(mockData),
} as Response);
const { result } = renderHook(() => useFetch('/api/user'));
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
// Wait for the fetch to complete
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
test('handles fetch errors', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(
new Error('Network failure')
);
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 failure');
});
waitFor repeatedly runs its callback until it passes (or times out). It's polling, not magic — keep the callback focused on a single assertion that signals "the async work is done." Then assert on other values outside waitFor.
The waitFor Gotcha
Don't stuff all your assertions inside waitFor:
// Bad — if data assertion fails, you get a timeout error
// instead of a meaningful assertion failure
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
// Good — wait for the signal, then assert everything else
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
When you put all assertions inside waitFor, a failing data assertion causes waitFor to keep retrying until it times out. You get a cryptic timeout error instead of a clear "expected X but got Y."
Testing Hooks That Return Callbacks
When your hook returns functions (callbacks), you need act to trigger them because they typically cause state updates:
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue((v) => !v);
const setOn = () => setValue(true);
const setOff = () => setValue(false);
return { value, toggle, setOn, setOff };
}
test('toggle flips the value', () => {
const { result } = renderHook(() => useToggle());
expect(result.current.value).toBe(false);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(false);
});
test('setOn and setOff are idempotent', () => {
const { result } = renderHook(() => useToggle());
act(() => {
result.current.setOn();
});
act(() => {
result.current.setOn();
});
expect(result.current.value).toBe(true);
act(() => {
result.current.setOff();
});
act(() => {
result.current.setOff();
});
expect(result.current.value).toBe(false);
});
Every call that triggers a state update or effect must be wrapped in act. This includes:
- Calling callbacks returned by the hook
- Advancing fake timers (
vi.advanceTimersByTime) - Resolving promises (when using
actwith async) - Firing events (though
userEventandfireEventhandle this internally)
Testing Hooks with Cleanup
Hooks that set up subscriptions, event listeners, or timers need to clean up when they unmount. Test this by calling unmount from renderHook:
function useWindowResize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handler = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
test('cleans up event listener on unmount', () => {
const addSpy = vi.spyOn(window, 'addEventListener');
const removeSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useWindowResize());
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
// Verify the same handler reference was used
const addedHandler = addSpy.mock.calls[0][1];
const removedHandler = removeSpy.mock.calls[0][1];
expect(addedHandler).toBe(removedHandler);
});
That last check is subtle but important. If the hook creates a new function reference in the cleanup (a common mistake with arrow functions in the wrong place), removeEventListener gets called with a different reference than addEventListener, and the listener never actually gets removed. The handler identity check catches that bug.
Testing Interval Cleanup
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
test('clears interval on unmount', () => {
vi.useFakeTimers();
const callback = vi.fn();
const { unmount } = renderHook(() => useInterval(callback, 1000));
act(() => {
vi.advanceTimersByTime(3000);
});
expect(callback).toHaveBeenCalledTimes(3);
unmount();
act(() => {
vi.advanceTimersByTime(3000);
});
// No additional calls after unmount
expect(callback).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
test('clears interval when delay becomes null', () => {
vi.useFakeTimers();
const callback = vi.fn();
const { rerender } = renderHook(
({ delay }) => useInterval(callback, delay),
{ initialProps: { delay: 1000 as number | null } }
);
act(() => {
vi.advanceTimersByTime(2000);
});
expect(callback).toHaveBeenCalledTimes(2);
// Pause the interval
rerender({ delay: null });
act(() => {
vi.advanceTimersByTime(5000);
});
// Still only 2 calls — interval was cleared
expect(callback).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
When to Test Hooks Directly vs Through Components
This is the question that sparks debates. Here's the practical answer:
Test the hook directly when:
- The hook is reused across multiple components
- The hook has complex state logic (state machines, reducers, multi-step flows)
- The hook manages side effects that are hard to observe through the UI (cleanup, abort signals, subscription management)
- You need to test edge cases that are hard to trigger through user interactions
- The hook's return value contract matters (specific return types, callback stability)
Test through a component when:
- The hook is tightly coupled to one specific component
- The hook's behavior is fully observable through rendered output
- Testing through the component naturally covers the hook's behavior
- The hook is simple enough that a dedicated test adds no value
The key insight: these approaches are complementary, not competing. A shared usePagination hook deserves its own test suite that verifies page calculation logic, boundary cases, and callback behavior. The component that uses it should test that clicking "Next" shows the right content — it shouldn't re-test the pagination math.
The Testing Library Philosophy and Hooks
Kent C. Dodds, the creator of Testing Library, initially recommended against testing hooks directly. The early guidance was: "Test components, not hooks." This led to the pattern of creating a TestComponent that renders the hook's return value as text and then asserting on the screen output.
That advice made sense in a world where renderHook didn't exist in the core library. But renderHook was added to @testing-library/react in v13.1 precisely because the community needed it. The updated guidance is pragmatic: test hooks directly when it gives you better coverage with less ceremony, test through components when the UI behavior is what matters.
The anti-pattern to avoid is writing a TestComponent just to render hook values as DOM text. That's renderHook with extra steps.
Putting It All Together — Testing a Real Hook
Here's a more realistic hook and its test suite to show how the patterns combine:
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
};
const removeValue = () => {
setStoredValue(initialValue);
window.localStorage.removeItem(key);
};
return [storedValue, setValue, removeValue] as const;
}
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
test('returns initial value when localStorage is empty', () => {
const { result } = renderHook(() =>
useLocalStorage('theme', 'light')
);
expect(result.current[0]).toBe('light');
});
test('reads existing value from localStorage', () => {
localStorage.setItem('theme', JSON.stringify('dark'));
const { result } = renderHook(() =>
useLocalStorage('theme', 'light')
);
expect(result.current[0]).toBe('dark');
});
test('writes to localStorage when value changes', () => {
const { result } = renderHook(() =>
useLocalStorage('count', 0)
);
act(() => {
result.current[1](42);
});
expect(result.current[0]).toBe(42);
expect(localStorage.getItem('count')).toBe('42');
});
test('supports functional updates', () => {
const { result } = renderHook(() =>
useLocalStorage('count', 10)
);
act(() => {
result.current[1]((prev) => prev + 5);
});
expect(result.current[0]).toBe(15);
});
test('removes value from localStorage', () => {
localStorage.setItem('name', JSON.stringify('Alice'));
const { result } = renderHook(() =>
useLocalStorage('name', 'default')
);
expect(result.current[0]).toBe('Alice');
act(() => {
result.current[2]();
});
expect(result.current[0]).toBe('default');
expect(localStorage.getItem('name')).toBeNull();
});
test('handles corrupted localStorage data gracefully', () => {
localStorage.setItem('data', 'not-valid-json{{{');
const { result } = renderHook(() =>
useLocalStorage('data', 'fallback')
);
expect(result.current[0]).toBe('fallback');
});
test('handles different keys independently', () => {
const { result: result1 } = renderHook(() =>
useLocalStorage('key1', 'a')
);
const { result: result2 } = renderHook(() =>
useLocalStorage('key2', 'b')
);
act(() => {
result1.current[1]('updated');
});
expect(result1.current[0]).toBe('updated');
expect(result2.current[0]).toBe('b');
});
});
Notice the test structure: each test is focused, descriptive, and tests one behavior. The beforeEach cleans up shared state (localStorage). Edge cases like corrupted data and key independence are covered explicitly.
- 1Always read from result.current, never destructure primitives before state updates
- 2Wrap every state-triggering action in act() to flush React updates synchronously
- 3Use waitFor for async hooks — put only the done-signal assertion inside, other assertions outside
- 4Use the wrapper option to provide context providers, not test-only wrapper components
- 5Test cleanup by calling unmount() and verifying listeners or timers were removed
- 6Use rerender() with new props to simulate parent re-renders, not unmount/remount
- 7Test hooks directly when reused or complex, through components when behavior is UI-observable
| What developers do | What they should do |
|---|---|
| Destructuring result.current into local variables and asserting on those after state changes Destructuring copies primitive values at that point in time. After a re-render, result.current updates but your local variable is stale. | Always read result.current.propertyName directly in assertions |
| Putting all assertions inside waitFor for async hooks If a non-signal assertion fails inside waitFor, it retries until timeout. You get a timeout error instead of a clear assertion failure message. | Put only the done-signal assertion in waitFor, then assert the rest outside |
| Creating a TestComponent that renders hook values as text, then using screen queries A TestComponent is renderHook with extra boilerplate. renderHook gives you direct access to the hook return value without encoding and decoding through the DOM. | Use renderHook directly — it exists for this exact purpose |
| Calling hook callbacks outside act() and wondering why result.current is stale Without act(), React may not flush state updates before your next assertion runs. This causes flaky tests that depend on internal React scheduling. | Wrap all calls that trigger state updates or effects in act() |
| Testing every custom hook directly, even simple one-liners used by a single component A hook like useFormattedDate that just wraps a Date method adds no value as a standalone test. The component test already covers it. Save direct hook tests for reusable or complex hooks. | Test simple, single-use hooks through the component that uses them |