Home/Learn/React/Testing Custom Hooks

Testing Custom Hooks

Advanced
Testing

renderHook runs a hook without a component. Use it for hooks with real logic, and test the rest through the components that use them.

Overview

A hook cannot be called outside a component, so testing one directly needs a harness — renderHook provides it, giving you the hook's return value and a way to trigger updates. It is the right tool for a hook containing genuine logic: a debounce, a pagination calculator, a reducer-backed workflow. It is the wrong tool for a hook that only exists to be used by one component, where testing through that component is both simpler and closer to what you actually care about.

renderHook and act

The API, and why updates have to be wrapped.

result.current is live; wrap updates in act
import { renderHook, act } from '@testing-library/react'

it('increments', () => {
  const { result } = renderHook(() => useCounter(0))

  expect(result.current.count).toBe(0)

  act(() => { result.current.increment() })     // state update -> act

  expect(result.current.count).toBe(1)
})

// result.current is always the LATEST return value. Destructuring
// it early captures a stale snapshot:
const { count } = result.current      // frozen at that moment
act(() => result.current.increment())
expect(count).toBe(1)                 // fails — count is still 0

// act tells React to flush the update and its effects before the
// next assertion. Without it you get the "not wrapped in act"
// warning and assertions that run against the previous render.

// userEvent and the async findBy queries call act internally, which
// is why component tests rarely need it explicitly.

Props, Rerenders and Timers

Testing a hook that reacts to changing input.

rerender, fake timers, wrapper, cleanup
it('debounces the value', () => {
  vi.useFakeTimers()

  const { result, rerender } = renderHook(
    ({ value }) => useDebouncedValue(value, 300),
    { initialProps: { value: 'a' } },
  )

  rerender({ value: 'ab' })
  expect(result.current).toBe('a')            // not yet

  act(() => { vi.advanceTimersByTime(300) })
  expect(result.current).toBe('ab')           // now

  vi.useRealTimers()
})

// Hooks needing context get a wrapper
const wrapper = ({ children }) => (
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useProblems('arrays'), { wrapper })

await waitFor(() => expect(result.current.isSuccess).toBe(true))

// Cleanup is worth asserting for anything that subscribes:
const { unmount } = renderHook(() => useEventListener('resize', fn))
unmount()
window.dispatchEvent(new Event('resize'))
expect(fn).not.toHaveBeenCalled()            // proves the cleanup ran

When Not to Use It

The judgement call, and the alternative.

Thin wrapper? Test the component
// Test the hook directly when it has logic of its own:
//   useDebouncedValue, usePagination, useFormWizard, a reducer hook

// Test through the component when the hook is a thin wrapper:
//   useProblemPage, which just composes a query and a permission check
it('locks the page for a free user', async () => {
  server.use(http.get('/api/problems/:slug', () => HttpResponse.json(proProblem)))
  renderWithProviders(<ProblemPage />, { route: '/problems/two-sum' })
  expect(await screen.findByRole('link', { name: /upgrade/i })).toBeInTheDocument()
})
// This test covers the hook AND its use, and it is the behaviour
// anyone actually cares about.

// A useful signal: if testing the hook requires reproducing half
// the component's context, test the component instead.

// And if the logic is pure, take it out of the hook entirely and
// test it as a function — no React, no harness, no act.

Key Points to Remember

  • 1renderHook runs a hook without a component and exposes its latest return value at result.current
  • 2Destructuring result.current captures a stale snapshot — always read it fresh after an update
  • 3Wrap state-changing calls in act so React flushes updates and effects before your assertions
  • 4rerender with new props tests hooks that react to changing input; a wrapper supplies required context
  • 5Test hooks with real logic directly, thin wrappers through their component, and pure logic as plain functions

Interview Questions

Sign in to ask Aria
1

Why do you need act() when testing a hook but rarely in component tests?

Hard
2

Why does destructuring result.current cause a failing assertion?

Hard
3

When is it better to test a hook through a component?

Medium

Ask Aria about Testing Custom Hooks

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…