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();
});