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.
Method 1: Testing useCallback within a Custom Hook (Recommended)
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');
});
});