How to Test useEffect Hook in React
Testing the useEffect hook in React is essential for
ensuring that side effects, such as data fetching, event subscriptions,
and manual DOM manipulations, behave correctly. This guide provides a
straightforward approach to testing useEffect using Jest
and React Testing Library. We will cover the core philosophy of testing
side effects, how to test mount-time effects, how to handle state
updates, and how to verify cleanup functions.
The Rule of Testing useEffect
When testing components that utilize the useEffect hook,
the primary rule is to test the user-facing behavior, not the
implementation details. You should not attempt to spy on or
mock the useEffect hook directly. Instead, render the
component, trigger the events that cause the effect to run, and assert
that the DOM updates correctly or that external APIs are called as
expected.
1. Testing On-Mount Effects (Data Fetching)
Many useEffect hooks run once when the component mounts
to fetch data from an API. To test this, you should mock the API call
and use asynchronous utilities from React Testing Library to wait for
the UI to update.
The Component:
import React, { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/user/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}The Test:
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock the global fetch API
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John Doe' }),
})
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('fetches and displays user data on mount', async () => {
render(<UserProfile userId="123" />);
// Check for the initial loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for the effect to resolve and update the DOM
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(global.fetch).toHaveBeenCalledWith('/api/user/123');
});2. Testing Effects with Dependencies
When an effect depends on prop or state changes, you need to simulate
those changes in your test. You can do this by rerendering the component
with new props using the rerender function returned by
React Testing Library’s render method.
The Test:
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
test('refetches data when userId prop changes', async () => {
const { rerender } = render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// Mock a different fetch response for the new prop
global.fetch.mockImplementationOnce(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'Jane Doe' }),
})
);
// Rerender with a new prop to trigger the useEffect dependency array
rerender(<UserProfile userId="456" />);
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
expect(global.fetch).toHaveBeenCalledWith('/api/user/456');
});3. Testing Cleanup Functions
If your useEffect hook returns a cleanup function (for
example, to remove event listeners or clear intervals), you must verify
that this cleanup runs when the component unmounts. You can trigger this
by calling the unmount function returned by
render.
The Component:
import React, { useEffect } from 'react';
export function WindowTracker() {
useEffect(() => {
const handleResize = () => console.log('Resized');
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
return <div>Tracking window size</div>;
}The Test:
import { render } from '@testing-library/react';
import { WindowTracker } from './WindowTracker';
test('removes window event listener on unmount', () => {
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = render(<WindowTracker />);
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
// Unmount the component to trigger the cleanup function
unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
addSpy.mockRestore();
removeSpy.mockRestore();
});