How to Test Custom Hooks in React

Testing custom hooks in React is essential for ensuring your application’s business logic remains robust, predictable, and bug-free. This article provides a straightforward guide on how to test React custom hooks using modern tools like React Testing Library and Jest, covering the use of renderHook for execution and act for handling state updates.

The Standard Tool: React Testing Library

Because React hooks cannot be called outside of a React component, you cannot test them like standard JavaScript functions. React Testing Library provides a built-in utility called renderHook specifically designed to solve this problem by wrapping the hook in a temporary, functional component during the test execution.

Step 1: Create a Simple Custom Hook

To demonstrate testing, let us look at a simple custom hook called useCounter that manages a count state and provides increment and decrement functions:

import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = useCallback(() => setCount((x) => x + 1), []);
  const decrement = useCallback(() => setCount((x) => x - 1), []);
  return { count, increment, decrement };
}

Step 2: Write the Test with renderHook

To test this hook, import renderHook from @testing-library/react. The renderHook function returns an object containing a result property. The result.current property always points to the latest return value of the hook.

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

test('should initialize and update counter', () => {
  // Render the hook
  const { result } = renderHook(() => useCounter(10));

  // Assert initial state
  expect(result.current.count).toBe(10);
});

Step 3: Testing State Updates with act()

When testing code that triggers state updates (like calling increment or decrement), you must wrap those interactions inside the act() utility. This ensures all state updates and effects are processed and flushed to the virtual DOM before you make your assertions.

test('should increment and decrement counter', () => {
  const { result } = renderHook(() => useCounter(0));

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

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

  // Trigger another state update
  act(() => {
    result.current.decrement();
  });

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

Testing Hooks with Dependencies

If your custom hook relies on context providers (such as Redux, React Router, or Theme Providers), you can pass a wrapper option to renderHook to supply the necessary context.

import { ThemeProvider } from './ThemeContext';

test('should use theme context value', () => {
  const wrapper = ({ children }) => (
    <ThemeProvider value="dark">{children}</ThemeProvider>
  );

  const { result } = renderHook(() => useThemeHook(), { wrapper });

  expect(result.current.theme).toBe('dark');
});