Why custom hooks need their own tests
When you extract logic into a custom hook, you've created a reusable unit with its own API contract: the arguments it accepts, the values it returns, and the side effects it produces. That contract deserves tests independent of any particular UI that uses the hook.
Without direct hook tests you have two bad options: write one massive component
test that exercises every edge case through the UI (slow, hard to read), or
skip edge cases and only test the golden path (dangerous). renderHook gives
you a third option: test the hook directly, at the granularity it deserves.
renderHook — the core API
renderHook from @testing-library/react mounts a minimal wrapper component
that calls the hook under test and exposes its return value through a result
object.
import { renderHook } from '@testing-library/react'
import { useCounter } from './useCounter'
test('initializes with given count', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
expect(typeof result.current.increment).toBe('function')
expect(typeof result.current.decrement).toBe('function')
})
result.current always holds the latest return value from the hook. It
updates whenever the hook causes a state change.
Testing with initial props and prop changes
Use initialProps to pass constructor arguments, and rerender to test how
the hook responds when its arguments change:
const { result, rerender } = renderHook(
({ step }) => useCounter(0, step),
{ initialProps: { step: 1 } }
)
expect(result.current.count).toBe(0)
rerender({ step: 5 })
// Hook now has a different step value
act() — the state-update wrapper
Any call that causes a state update inside renderHook must be wrapped in
act(). This tells React to flush the update before assertions run.
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'
test('toggles boolean state', () => {
const { result } = renderHook(() => useToggle(false))
expect(result.current.isOn).toBe(false)
act(() => {
result.current.toggle()
})
expect(result.current.isOn).toBe(true)
})
For async state updates:
await act(async () => {
result.current.fetchData()
})
// All state updates from the async operation are flushed
You'll see the "not wrapped in act" warning whenever a state update escapes
test control. The fix is always to find the trigger and wrap it in act.
Testing async hooks
Async hooks (those that fetch data or run async operations) use the same
MSW + waitFor pattern as component tests:
import { renderHook, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { useFetchUser } from './useFetchUser'
test('loading → data transition', async () => {
server.use(
http.get('/api/users/1', () =>
HttpResponse.json({ id: 1, name: 'Alice', role: 'admin' })
)
)
const { result } = renderHook(() => useFetchUser(1))
// Immediately after mount — loading
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeNull()
// Wait for the async update
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.user).toEqual({ id: 1, name: 'Alice', role: 'admin' })
expect(result.current.error).toBeNull()
})
test('loading → error transition', async () => {
server.use(
http.get('/api/users/1', () => new HttpResponse(null, { status: 500 }))
)
const { result } = renderHook(() => useFetchUser(1))
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.error).toBeTruthy()
expect(result.current.user).toBeNull()
})
Use waitFor(() => expect(...)) rather than findBy* — there's no DOM
to find; you're waiting for hook state values to change.
Testing context-dependent hooks
Pass a wrapper option to renderHook to provide the required context:
import { renderHook } from '@testing-library/react'
import { AuthProvider } from './AuthContext'
import { useAuth } from './useAuth'
function createWrapper(user) {
return function Wrapper({ children }) {
return <AuthProvider initialUser={user}>{children}</AuthProvider>
}
}
test('returns authenticated user from context', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper({ id: 1, name: 'Alice', role: 'admin' }),
})
expect(result.current.user.name).toBe('Alice')
expect(result.current.isAuthenticated).toBe(true)
})
test('returns null when no user', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(null),
})
expect(result.current.user).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
})
test('throws outside provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => renderHook(() => useAuth())).toThrow(/must be used within AuthProvider/i)
consoleSpy.mockRestore()
})
Always test the "outside provider" case — it verifies your guard condition gives developers a clear message in development.
Testing useEffect side effects
Effects run synchronously during renderHook (RTL wraps it in act
automatically). Test initial effects, update-triggered effects, and cleanup.
import { renderHook } from '@testing-library/react'
import { useDocumentTitle } from './useDocumentTitle'
test('sets document title on mount', () => {
renderHook(() => useDocumentTitle('My Dashboard'))
expect(document.title).toBe('My Dashboard')
})
test('updates title when value prop changes', () => {
const { rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'Page One' } }
)
expect(document.title).toBe('Page One')
rerender({ title: 'Page Two' })
expect(document.title).toBe('Page Two')
})
test('restores original title on unmount', () => {
document.title = 'App'
const { unmount } = renderHook(() => useDocumentTitle('Temporary'))
expect(document.title).toBe('Temporary')
unmount()
expect(document.title).toBe('App') // restored by cleanup return
})
Testing timer hooks
Fake timers make timer-based hooks testable without real waiting:
import { renderHook, act } from '@testing-library/react'
import { vi } from 'vitest'
import { useDebounce } from './useDebounce'
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
test('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 300))
expect(result.current).toBe('initial')
})
test('delays update until timeout elapses', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'first' } }
)
rerender({ value: 'second' })
// Before timeout
act(() => vi.advanceTimersByTime(200))
expect(result.current).toBe('first')
// After timeout
act(() => vi.advanceTimersByTime(100))
expect(result.current).toBe('second')
})
test('clears pending timer on unmount', () => {
const clearSpy = vi.spyOn(global, 'clearTimeout')
const { unmount } = renderHook(() => useDebounce('test', 300))
unmount()
expect(clearSpy).toHaveBeenCalled()
})
})
Three tests for every timer hook: initial value, debounce behavior, and cleanup.
Testing useReducer-based hooks
Test through the hook's public action API, not by inspecting the reducer:
import { renderHook, act } from '@testing-library/react'
import { useShoppingCart } from './useShoppingCart'
test('add and remove items', () => {
const { result } = renderHook(() => useShoppingCart())
expect(result.current.items).toHaveLength(0)
act(() => {
result.current.addItem({ id: 1, name: 'Widget', price: 9.99 })
})
act(() => {
result.current.addItem({ id: 2, name: 'Gadget', price: 24.99 })
})
expect(result.current.items).toHaveLength(2)
expect(result.current.total).toBeCloseTo(34.98)
act(() => {
result.current.removeItem(1)
})
expect(result.current.items).toHaveLength(1)
expect(result.current.total).toBeCloseTo(24.99)
})
Separately unit-test the reducer as a pure function:
import { cartReducer } from './cartReducer'
test('ADD_ITEM increments quantity for existing item', () => {
const state = { items: [{ id: 1, qty: 1, price: 10 }] }
const result = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 1 } })
expect(result.items[0].qty).toBe(2)
})
Testing hooks with arguments (prop changes)
import { renderHook } from '@testing-library/react'
import { usePagination } from './usePagination'
test('recalculates on pageSize change', () => {
const { result, rerender } = renderHook(
({ pageSize }) => usePagination({ page: 1, pageSize, total: 100 }),
{ initialProps: { pageSize: 10 } }
)
expect(result.current.totalPages).toBe(10)
rerender({ pageSize: 25 })
expect(result.current.totalPages).toBe(4)
})
Mocking hook dependencies
Use vi.mock() to replace modules the hook depends on:
import { vi } from 'vitest'
import * as analytics from '../analytics'
import { useProductView } from './useProductView'
vi.mock('../analytics')
const mockedAnalytics = vi.mocked(analytics)
beforeEach(() => mockedAnalytics.trackEvent.mockImplementation(() => {}))
afterEach(() => vi.clearAllMocks())
test('tracks view event on mount', () => {
renderHook(() => useProductView({ id: 42, name: 'Widget' }))
expect(mockedAnalytics.trackEvent).toHaveBeenCalledWith('product_view', {
product_id: 42,
product_name: 'Widget',
})
})
test('tracks only once regardless of re-renders', () => {
const { rerender } = renderHook(() => useProductView({ id: 42, name: 'Widget' }))
rerender()
rerender()
expect(mockedAnalytics.trackEvent).toHaveBeenCalledTimes(1)
})
Testing hooks that use refs
renderHook doesn't produce DOM elements — wrap the hook in a component
when the hook needs a real DOM node attached to a ref:
test('useAutoFocus focuses the element on mount', () => {
function Fixture() {
const ref = React.useRef(null)
useAutoFocus(ref)
return <input ref={ref} aria-label="Auto-focused" />
}
render(<Fixture />)
expect(screen.getByRole('textbox', { name: /auto-focused/i })).toHaveFocus()
})
This is the natural break point: hooks that need the DOM are best tested
through a minimal component fixture, not renderHook.
Testing hook cleanup
Every hook that registers listeners, starts timers, or opens subscriptions
must clean up on unmount. Verify it with unmount():
test('useWindowResize removes listener on unmount', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const { unmount } = renderHook(() => useWindowResize(() => {}))
const [, addedHandler] = addSpy.mock.calls[0] // capture the handler
unmount()
expect(removeSpy).toHaveBeenCalledWith('resize', addedHandler)
addSpy.mockRestore()
removeSpy.mockRestore()
})
Cleanup test matters because leaked listeners cause:
- Memory leaks in production.
- "Can't perform state update on unmounted component" warnings.
act()warnings in subsequent tests.
When to test hooks directly vs through components
This question comes up in nearly every testing interview. The answer is not black-and-white:
Test through the component when:
- The hook is only used in one place.
- The hook's behavior is completely visible through the UI.
- The hook is simple (trivial state, no complex async logic).
// useSearch is only used in SearchPage — test through SearchPage
test('search results update as user types', async () => {
const user = userEvent.setup()
render(<SearchPage />)
await user.type(screen.getByRole('textbox'), 'react')
expect(await screen.findByText('React Hooks')).toBeInTheDocument()
})
Test the hook directly when:
- The hook is reused across multiple components.
- The hook manages complex state transitions (async, state machines, caching).
- The hook has edge cases that are impractical to exercise through UI.
- You want to document the hook's API contract explicitly.
// useInfiniteScroll is used in 5 components — test directly
test('loads next page when fetchNextPage is called', async () => {
const { result } = renderHook(() => useInfiniteScroll(fetchPage))
await waitFor(() => expect(result.current.pages).toHaveLength(1))
act(() => result.current.fetchNextPage())
await waitFor(() => expect(result.current.pages).toHaveLength(2))
})
The practical middle ground: one component test per major use case, plus direct hook tests for edge cases and the API contract.
Testing hooks that throw
Suppress console.error and wrap the call in expect(...).toThrow():
test('throws when used outside provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => renderHook(() => useTheme())).toThrow(
'useTheme must be used inside ThemeProvider'
)
consoleSpy.mockRestore()
})
Interview checklist
Make sure you can explain and demonstrate:
- How
renderHookworks and whatresult.currentholds. - Why
act()is required and how to apply it to sync vs async updates. - How to test async hooks with MSW and
waitFor. - How to provide context providers to
renderHookviawrapper. - How to test
useEffecteffects at mount, update, and unmount. - How to control timers in hook tests with
vi.useFakeTimers. - How to verify cleanup with
unmount()and event listener spies. - When to test a hook directly vs through the component that uses it.
Rule of thumb: Test hooks through their public return value, not their
internals. If you find yourself mocking useState or useReducer, you're
testing implementation — step back and test what callers see instead.