Skip to content

React · Testing

Testing React Custom Hooks — Complete Guide

12 min read Updated 2026-06-24 Share:

Practice Testing Custom Hooks interview questions

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 renderHook works and what result.current holds.
  • 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 renderHook via wrapper.
  • How to test useEffect effects 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel