How to Test useCallback Hook in React

This article explains how to effectively test the useCallback hook in React. You will learn how to verify that your memoized functions maintain referential equality when dependencies remain unchanged, and how they update correctly when dependencies change. We will cover testing useCallback within custom hooks using renderHook and testing its behavior inside components using React Testing Library.

Understanding the Testing Strategy

The useCallback hook cache-stores a function definition between re-renders. When testing it, your primary goal is to verify referential integrity. You need to ensure that: 1. The function reference remains the same (strict equality toBe) when dependencies do not change. 2. The function reference changes (is recreated) when dependencies do change.

There are two primary methods to test this: testing it as part of a custom hook, or testing it implicitly through component rendering behavior.


Testing useCallback inside a custom hook is the most direct way to assert referential equality. By using renderHook from @testing-library/react, you can easily monitor if the function reference changes across hook re-renders.

The Custom Hook Example

import { useState, useCallback } from 'react';

export const useCounterCallback = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);
  const [multiplier, setMultiplier] = useState(1);

  // This callback only updates when multiplier changes
  const multiplyCount = useCallback(() => {
    return count * multiplier;
  }, [multiplier]); // count is omitted intentionally for this demonstration

  return { count, setCount, multiplier, setMultiplier, multiplyCount };
};

The Test Suite

Using Jest and @testing-library/react, you can assert the strict equality of the returned multiplyCount function.

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

describe('useCounterCallback Hook', () => {
  test('should memoize the callback and only update when dependencies change', () => {
    const { result, rerender } = renderHook(() => useCounterCallback(5));

    // Store the initial function reference
    const firstCallbackInstance = result.current.multiplyCount;

    // Trigger a state change that is NOT a dependency (count)
    act(() => {
      result.current.setCount(10);
    });

    // Re-render the hook to get the updated values
    rerender();

    // The callback reference should remain exactly the same
    expect(result.current.multiplyCount).toBe(firstCallbackInstance);
    expect(result.current.multiplyCount()).toBe(10); // 10 * 1

    // Trigger a state change that IS a dependency (multiplier)
    act(() => {
      result.current.setMultiplier(3);
    });

    rerender();

    // The callback reference should now be different
    expect(result.current.multiplyCount).not.toBe(firstCallbackInstance);
    expect(result.current.multiplyCount()).toBe(30); // 10 * 3
  });
});

Method 2: Testing useCallback inside a Component

If your useCallback is embedded inside a component, you should test its behavior rather than its implementation. You do this by verifying that child components wrapped in React.memo do not re-render unnecessarily.

The Components

import React, { useState, useCallback } from 'react';

// Memoized child component
const ChildButton = React.memo(({ onClick, label }) => {
  const renderCount = React.useRef(0);
  renderCount.current += 1;
  
  return (
    <button onClick={onClick} data-testid="child-btn">
      {label} - Renders: {renderCount.current}
    </button>
  );
});

export const ParentComponent = () => {
  const [text, setText] = useState('');
  const [count, setCount] = useState(0);

  // Memoized callback dependency on count
  const handleClick = useCallback(() => {
    console.log('Clicked', count);
  }, [count]);

  return (
    <div>
      <input 
        type="text" 
        value={text} 
        onChange={(e) => setText(e.target.value)} 
        placeholder="Type here"
      />
      <ChildButton onClick={handleClick} label="Submit" />
    </div>
  );
};

The Test Suite

In this test, we verify that updating the input field (which changes the parent’s text state) does not trigger a re-render of the memoized child component because the handleClick dependency (count) hasn’t changed.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ParentComponent } from './ParentComponent';

describe('ParentComponent useCallback behavior', () => {
  test('does not re-render memoized child when unrelated parent state changes', async () => {
    render(<ParentComponent />);
    const childButton = screen.getByTestId('child-btn');
    const input = screen.getByPlaceholderText('Type here');

    // Initial render count of the child button should be 1
    expect(childButton).toHaveTextContent('Submit - Renders: 1');

    // Type in the input field to trigger parent state updates
    await userEvent.type(input, 'Hello');

    // Child component should NOT re-render because useCallback kept the function reference identical
    expect(childButton).toHaveTextContent('Submit - Renders: 1');
  });
});