How to test hooks in React

Testing custom React hooks ensures your reusable hook logic works correctly and handles edge cases properly. With over 12 years of React development experience since 2014 and as the creator of CoreUI, I’ve written and tested hundreds of custom hooks. React Testing Library provides renderHook utility specifically designed for testing hooks in isolation without needing a component. This approach allows you to test hook logic, state updates, and side effects independently.

Use renderHook from React Testing Library to test custom React hooks in isolation.

Install dependencies:

npm install --save-dev @testing-library/react @testing-library/react-hooks

Simple counter hook:

// useCounter.js
import { useState } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)

  return { count, increment, decrement, reset }
}

Test the hook:

// useCounter.test.js
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

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

    act(() => {
      result.current.increment()
    })

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

  it('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.decrement()
    })

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

  it('resets counter', () => {
    const { result } = renderHook(() => useCounter(10))

    act(() => {
      result.current.increment()
      result.current.increment()
    })

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

    act(() => {
      result.current.reset()
    })

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

Testing hook with props:

// useFetch.js
import { useState, useEffect } from 'react'

export function useFetch(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!url) return

    setLoading(true)
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data)
        setError(null)
      })
      .catch(err => {
        setError(err.message)
        setData(null)
      })
      .finally(() => setLoading(false))
  }, [url])

  return { data, loading, error }
}

// useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'

global.fetch = jest.fn()

describe('useFetch', () => {
  beforeEach(() => {
    fetch.mockClear()
  })

  it('fetches data successfully', async () => {
    const mockData = { id: 1, name: 'John' }
    fetch.mockResolvedValueOnce({
      json: async () => mockData
    })

    const { result } = renderHook(() => useFetch('/api/users/1'))

    expect(result.current.loading).toBe(true)
    expect(result.current.data).toBe(null)

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.data).toEqual(mockData)
    expect(result.current.error).toBe(null)
  })

  it('handles fetch error', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'))

    const { result } = renderHook(() => useFetch('/api/users/1'))

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.error).toBe('Network error')
    expect(result.current.data).toBe(null)
  })

  it('refetches when URL changes', async () => {
    fetch.mockResolvedValue({
      json: async () => ({ id: 1 })
    })

    const { result, rerender } = renderHook(
      ({ url }) => useFetch(url),
      { initialProps: { url: '/api/users/1' } }
    )

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(fetch).toHaveBeenCalledTimes(1)

    rerender({ url: '/api/users/2' })

    await waitFor(() => {
      expect(fetch).toHaveBeenCalledTimes(2)
    })
  })
})

Testing hook with context:

// useAuth.js
import { useContext } from 'react'
import { AuthContext } from './AuthContext'

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

// useAuth.test.js
import { renderHook } from '@testing-library/react'
import { AuthProvider } from './AuthContext'
import { useAuth } from './useAuth'

describe('useAuth', () => {
  it('returns auth context', () => {
    const wrapper = ({ children }) => (
      <AuthProvider>{children}</AuthProvider>
    )

    const { result } = renderHook(() => useAuth(), { wrapper })

    expect(result.current).toHaveProperty('user')
    expect(result.current).toHaveProperty('login')
    expect(result.current).toHaveProperty('logout')
  })

  it('throws error when used outside provider', () => {
    expect(() => {
      renderHook(() => useAuth())
    }).toThrow('useAuth must be used within AuthProvider')
  })
})

Testing hook with dependencies:

// useDebounce.js
import { useState, useEffect } from 'react'

export function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(handler)
  }, [value, delay])

  return debouncedValue
}

// useDebounce.test.js
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from './useDebounce'

jest.useFakeTimers()

describe('useDebounce', () => {
  it('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 500))
    expect(result.current).toBe('initial')
  })

  it('debounces value changes', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    )

    expect(result.current).toBe('initial')

    rerender({ value: 'updated', delay: 500 })

    expect(result.current).toBe('initial')

    act(() => {
      jest.advanceTimersByTime(500)
    })

    expect(result.current).toBe('updated')
  })

  it('cancels previous timeout on rapid changes', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'first' } }
    )

    rerender({ value: 'second' })
    act(() => jest.advanceTimersByTime(300))

    rerender({ value: 'third' })
    act(() => jest.advanceTimersByTime(300))

    expect(result.current).toBe('first')

    act(() => jest.advanceTimersByTime(200))

    expect(result.current).toBe('third')
  })
})

Best Practice Note

Use renderHook to test hooks in isolation without creating test components. Wrap state updates in act() to ensure React processes updates. Use waitFor for async operations and side effects. Use rerender to test how hooks respond to prop changes. Mock external dependencies like fetch using Jest. Use wrapper option to provide context or providers needed by the hook. Test edge cases like empty values, errors, and cleanup. This is how we test custom hooks in CoreUI React components—comprehensive unit tests ensuring hook logic is reliable, reusable, and handles all scenarios correctly.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author