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. It is the standard way to test hooks in
isolation without building a throwaway UI component.
import { renderHook } from '@testing-library/react'
import { useCounter } from './useCounter'
test('initializes with given value', () => {
const { result } = renderHook(() => useCounter(10))
// result.current holds whatever the hook returns
expect(result.current.count).toBe(10)
expect(typeof result.current.increment).toBe('function')
})
The wrapper component re-renders each time the hook causes a state
update, and result.current always reflects the latest return value.
Internally, renderHook(callback) is roughly:
function TestHook() {
result.current = callback() // captures hook return value
return null
}
render(<TestHook />)
Updating arguments on re-render:
const { result, rerender } = renderHook(
({ step }) => useCounter(0, step),
{ initialProps: { step: 1 } }
)
rerender({ step: 5 }) // hook re-executes with new props
Rule of thumb: Use renderHook when the hook encapsulates logic
you want to verify independently; use component tests when the
hook is simple and fully covered by the component's test.
React batches state updates and applies them asynchronously in its
fiber scheduler. act() tells React's test utilities: "all state changes
inside this block should be flushed before assertions run." Without it
you may assert before React has committed the updated state.
import { renderHook, act } from '@testing-library/react'
import { useToggle } from './useToggle'
test('toggles between true and false', () => {
const { result } = renderHook(() => useToggle(false))
expect(result.current.isOn).toBe(false)
// Wrap the state-updating call in act()
act(() => {
result.current.toggle()
})
expect(result.current.isOn).toBe(true)
act(() => {
result.current.toggle()
})
expect(result.current.isOn).toBe(false)
})
For async updates (hooks that call setState inside a Promise or timer):
await act(async () => {
result.current.fetchData()
})
// Safe to assert here — all async state changes have been flushed
RTL's userEvent helpers wrap their actions in act automatically, which
is one reason to prefer userEvent over manual act calls in component
tests. For hook tests you usually call act directly.
Rule of thumb: Any call inside renderHook that triggers a state
update must be wrapped in act(). If you see the "not wrapped in act"
warning, find the state update and wrap the trigger.
Combine renderHook, waitFor, and a mock (MSW or vi.fn()) to assert
on loading, success, and error states.
import { renderHook, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { useFetchUser } from './useFetchUser'
// useFetchUser: returns { user, loading, error }
test('transitions loading → data', async () => {
server.use(
http.get('/api/users/1', () =>
HttpResponse.json({ id: 1, name: 'Alice' })
)
)
const { result } = renderHook(() => useFetchUser(1))
// Loading state immediately after mount
expect(result.current.loading).toBe(true)
expect(result.current.user).toBeNull()
// Wait until loading is false
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.user).toEqual({ id: 1, name: 'Alice' })
expect(result.current.error).toBeNull()
})
test('sets error on fetch failure', 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()
})
Rule of thumb: Use waitFor(() => expect(...)) rather than
findBy* in hook tests because hooks return values, not DOM elements.
Pass a wrapper option to renderHook that wraps the hook in the
required provider, the same way you'd wrap a component.
import { renderHook } from '@testing-library/react'
import { AuthProvider } from './AuthContext'
import { useAuth } from './useAuth'
function createWrapper(user) {
return function AuthWrapper({ children }) {
return <AuthProvider initialUser={user}>{children}</AuthProvider>
}
}
test('returns current user from context', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper({ id: 1, name: 'Alice', role: 'admin' }),
})
expect(result.current.user).toEqual({ id: 1, name: 'Alice', role: 'admin' })
expect(result.current.isAuthenticated).toBe(true)
})
test('returns null user when not authenticated', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(null),
})
expect(result.current.user).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
})
test('throws when used outside AuthProvider', () => {
// No wrapper — hook should throw
expect(() => renderHook(() => useAuth())).toThrow(/must be used within AuthProvider/i)
})
Rule of thumb: Always test the "missing provider" error path for
hooks that call useContext — it verifies your guard condition and
produces a clear error message in production.
Wrap the side-effectful action in act() and assert the result after
act resolves. For side effects that happen on mount, they run
synchronously during renderHook (within RTL's own act call).
import { renderHook, act } from '@testing-library/react'
import { useDocumentTitle } from './useDocumentTitle'
// useDocumentTitle: sets document.title to the given string
test('sets document title on mount', () => {
renderHook(() => useDocumentTitle('My Page'))
expect(document.title).toBe('My Page')
})
test('updates document title when value changes', () => {
const { rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'Page 1' } }
)
expect(document.title).toBe('Page 1')
rerender({ title: 'Page 2' })
expect(document.title).toBe('Page 2')
})
test('restores title on unmount', () => {
document.title = 'Original'
const { unmount } = renderHook(() => useDocumentTitle('Temp'))
expect(document.title).toBe('Temp')
unmount()
expect(document.title).toBe('Original')
})
For async effects (data fetching on mount), use waitFor as shown in
the useFetchUser example.
Rule of thumb: Test three lifecycle moments: initial mount effect, update-triggered effect, and cleanup on unmount.
Use vi.useFakeTimers() and advance time inside act().
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('hello', 300))
expect(result.current).toBe('hello')
})
test('does not update before delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'hello' } }
)
rerender({ value: 'world' })
act(() => vi.advanceTimersByTime(100))
expect(result.current).toBe('hello') // not yet updated
})
test('updates after debounce delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'hello' } }
)
rerender({ value: 'world' })
act(() => vi.advanceTimersByTime(300))
expect(result.current).toBe('world')
})
test('clears pending timer on unmount', () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
const { unmount } = renderHook(() => useDebounce('test', 300))
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
Rule of thumb: Always test the cleanup path (unmount) for timer
hooks — leaking timers is a common source of "act warning" failures in
subsequent tests.
Test through the hook's public interface (the actions/dispatch wrappers it returns), not by inspecting the reducer directly. The reducer itself can have its own pure unit tests.
import { renderHook, act } from '@testing-library/react'
import { useShoppingCart } from './useShoppingCart'
test('adds and removes items', () => {
const { result } = renderHook(() => useShoppingCart())
expect(result.current.items).toHaveLength(0)
expect(result.current.total).toBe(0)
act(() => {
result.current.addItem({ id: 1, name: 'Widget', price: 9.99 })
})
expect(result.current.items).toHaveLength(1)
expect(result.current.total).toBeCloseTo(9.99)
act(() => {
result.current.addItem({ id: 2, name: 'Gadget', price: 24.99 })
})
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)
})
test('clears cart', () => {
const { result } = renderHook(() => useShoppingCart())
act(() => {
result.current.addItem({ id: 1, name: 'Widget', price: 9.99 })
result.current.clearCart()
})
expect(result.current.items).toHaveLength(0)
})
Separately, test the reducer as a pure function:
import { cartReducer } from './cartReducer'
test('ADD_ITEM increases quantity for existing item', () => {
const state = { items: [{ id: 1, qty: 1, price: 10 }] }
const next = cartReducer(state, { type: 'ADD_ITEM', payload: { id: 1 } })
expect(next.items[0].qty).toBe(2)
})
Rule of thumb: Test hooks through their action API (what the caller sees); test reducers as pure functions (same input → same output).
Pass initialProps to renderHook and call rerender with new props
to simulate prop changes.
import { renderHook } from '@testing-library/react'
import { usePagination } from './usePagination'
test('initializes with given page and pageSize', () => {
const { result } = renderHook(
({ page, pageSize }) => usePagination({ page, pageSize, total: 100 }),
{ initialProps: { page: 1, pageSize: 10 } }
)
expect(result.current.currentPage).toBe(1)
expect(result.current.totalPages).toBe(10)
expect(result.current.startIndex).toBe(0)
expect(result.current.endIndex).toBe(9)
})
test('recalculates when pageSize changes', () => {
const { result, rerender } = renderHook(
({ pageSize }) => usePagination({ page: 1, pageSize, total: 100 }),
{ initialProps: { pageSize: 10 } }
)
expect(result.current.totalPages).toBe(10)
rerender({ pageSize: 20 })
expect(result.current.totalPages).toBe(5)
})
test('clamps page when it exceeds new totalPages', () => {
const { result, rerender } = renderHook(
({ total }) => usePagination({ page: 5, pageSize: 10, total }),
{ initialProps: { total: 100 } }
)
expect(result.current.currentPage).toBe(5)
rerender({ total: 30 }) // now only 3 pages
expect(result.current.currentPage).toBe(3) // clamped
})
Rule of thumb: Use initialProps + rerender to test argument
changes; this is the hook equivalent of testing prop changes via
render + rerender.
Both are valid — the choice depends on the complexity and reuse of the hook.
Test through the component when:
- The hook is tightly coupled to one component and used nowhere else.
- The hook's behavior is fully visible through the component's UI.
- The hook is simple (one or two state variables).
// useExpanded is used only in Accordion — test through Accordion
test('expands panel on click', async () => {
const user = userEvent.setup()
render(<Accordion panels={[{ title: 'Q1', body: 'A1' }]} />)
expect(screen.queryByText('A1')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Q1/i }))
expect(screen.getByText('A1')).toBeInTheDocument()
})
Test the hook directly when:
- The hook encapsulates complex logic used by multiple components.
- The hook has many edge cases that would be tedious to exercise via UI.
- The hook manages async state (loading/error/data transitions).
- You want to document the hook's API contract separately.
// useInfiniteScroll is reused across 4 components — test directly
test('loads next page when sentinel is visible', async () => {
const { result } = renderHook(() => useInfiniteScroll(fetchPage))
// ... assert loading, page count, hasMore
})
A practical middle ground: write one integration test per major use-case at the component level, then add direct hook tests for tricky edge cases.
Rule of thumb: Start with component tests. Add direct hook tests when the component test becomes unwieldy or when the hook is shared.
Use vi.mock() to replace the dependency before the hook (and any
component using it) imports it.
import { renderHook } from '@testing-library/react'
import { vi } from 'vitest'
import * as analyticsModule from '../utils/analytics'
import { useProductView } from './useProductView'
vi.mock('../utils/analytics')
const mockedAnalytics = vi.mocked(analyticsModule)
beforeEach(() => {
mockedAnalytics.trackEvent.mockImplementation(() => {})
})
afterEach(() => vi.clearAllMocks())
test('tracks product 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 even if hook re-renders', () => {
const { rerender } = renderHook(() => useProductView({ id: 42, name: 'Widget' }))
rerender()
rerender()
expect(mockedAnalytics.trackEvent).toHaveBeenCalledTimes(1)
})
For mocking a hook that's used inside another hook (hook composition):
vi.mock('./useAuth', () => ({
useAuth: () => ({ user: { id: 1 }, isAuthenticated: true }),
}))
Rule of thumb: Mock at the module boundary, not deep inside the implementation. If you're mocking an internal helper, the hook's responsibility boundary may be too large.
renderHook by itself doesn't produce DOM elements — you need a component
to attach refs to real DOM nodes. Use a custom wrapper component.
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useAutoFocus } from './useAutoFocus'
// useAutoFocus: takes a ref and calls .focus() on mount
test('focuses element on mount', () => {
function TestComponent() {
const ref = React.useRef(null)
useAutoFocus(ref)
return <input ref={ref} aria-label="Auto-focused input" />
}
render(<TestComponent />)
expect(screen.getByRole('textbox', { name: /auto-focused/i })).toHaveFocus()
})
For hooks that expose a ref (e.g., useScrollLock):
function TestComponent() {
const containerRef = React.useRef(null)
const { lock, unlock } = useScrollLock(containerRef)
return (
<div ref={containerRef} style={{ overflow: 'auto' }}>
<button onClick={lock}>Lock</button>
<button onClick={unlock}>Unlock</button>
</div>
)
}
test('sets overflow hidden on lock', async () => {
const user = userEvent.setup()
render(<TestComponent />)
await user.click(screen.getByRole('button', { name: /lock/i }))
// Assert DOM side-effect
expect(screen.getByRole('button', { name: /lock/i }).closest('div'))
.toHaveStyle({ overflow: 'hidden' })
})
Rule of thumb: When a hook needs a real DOM node (for refs,
resize observers, intersection observers), write a small wrapper
component in the test file rather than forcing renderHook to do it.
Call unmount() from the renderHook result, then assert that side
effects have been reversed — event listeners removed, timers cleared,
subscriptions cancelled.
import { renderHook } from '@testing-library/react'
import { vi } from 'vitest'
import { useWindowResize } from './useWindowResize'
test('removes 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()
// Same handler that was added should be removed
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
addSpy.mockRestore()
removeSpy.mockRestore()
})
test('cancels pending subscription on unmount', () => {
const unsubscribe = vi.fn()
const subscribe = vi.fn().mockReturnValue(unsubscribe)
const { unmount } = renderHook(() => useStoreSubscription(subscribe))
expect(subscribe).toHaveBeenCalledTimes(1)
unmount()
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
Cleanup tests matter because leaking subscriptions or listeners cause:
- Memory leaks in production.
- "Can't perform state update on unmounted component" warnings.
- State updates firing after the component is gone (act warnings in tests).
Rule of thumb: Write an unmount cleanup test for every hook that registers event listeners, starts timers, or opens subscriptions.
Wrap the renderHook call in a try/catch or use
expect(() => ...).toThrow(). Because React catches errors during
render and logs them, suppress console.error too.
import { renderHook } from '@testing-library/react'
import { vi } from 'vitest'
import { useRequiredContext } from './useRequiredContext'
test('throws when used outside provider', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => renderHook(() => useRequiredContext())).toThrow(
'useRequiredContext must be used inside RequiredContextProvider'
)
consoleSpy.mockRestore()
})
For hooks that validate their arguments:
test('throws for negative page size', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() =>
renderHook(() => usePagination({ page: 1, pageSize: -1, total: 100 }))
).toThrow('pageSize must be positive')
consoleSpy.mockRestore()
})
Note: React 18 with concurrent mode may not throw immediately on the
first render — if you find that expect(...).toThrow() doesn't catch
the error, use an Error Boundary wrapper instead.
Rule of thumb: Test the error guard condition (missing provider,
invalid args) so users get a clear message in development. Suppress
console.error in these tests to keep output clean.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.