How to Mock React Hydration

Mocking React hydration is a crucial technique for developers testing server-side rendered (SSR) applications. This article explains how to mock the hydration process and simulate hydration states in your unit tests. By controlling the hydration phase, you can prevent hydration mismatch warnings in your test suites and verify that your components render correctly on both the server and the client.

Understanding the Need to Mock Hydration

Hydration is the process where React attaches event listeners to the static HTML sent from the server, making the page interactive. In a testing environment like Jest or Vitest with React Testing Library, simulating this transition is difficult because the test runner typically operates in a pure client-side DOM environment (jsdom).

Mocking hydration helps you: 1. Prevent Mismatch Warnings: Avoid the “Text content did not match” errors in your test logs. 2. Test Dual-State Components: Verify components that render different content on the server versus the client (e.g., hiding a client-only widget until hydration is complete).


Mocking React 18 hydrateRoot

If you are testing the entry point of your application, you may need to mock the actual hydrateRoot call from react-dom/client. You can achieve this by mocking the module in Jest.

import { hydrateRoot } from 'react-dom/client';

jest.mock('react-dom/client', () => {
  return {
    __esModule: true,
    hydrateRoot: jest.fn(),
  };
});

describe('Application Bootstrapping', () => {
  it('should call hydrateRoot during initialization', () => {
    // Trigger your app entry point here
    require('./index.js'); 
    
    expect(hydrateRoot).toHaveBeenCalled();
  });
});

Mocking the Hydration State in Components

Most hydration testing issues arise when components behave differently before and after hydration. Developers often use a two-pass rendering pattern with a state variable (e.g., isMounted or isHydrated) to handle this.

Here is a typical component utilizing this pattern:

import { useState, useEffect } from 'react';

export function ClientOnlyComponent() {
  const [isHydrated, setIsHydrated] = useState(false);

  useEffect(() => {
    setIsHydrated(true);
  }, []);

  if (!isHydrated) {
    return <div>Loading server content...</div>;
  }

  return <div>Interactive Client Content</div>;
}

To test both states of this component, you must control the React lifecycle in your tests.

Testing the Pre-Hydration (Server) State

To test the initial server render before useEffect triggers the client-side hydration, you can mock or suppress the lifecycle temporarily, or render the component using standard string rendering:

import React from 'react';
import ReactDOMServer from 'react-dom/server';

test('renders server-side HTML before hydration', () => {
  const html = ReactDOMServer.renderToString(<ClientOnlyComponent />);
  expect(html).toContain('Loading server content...');
  expect(html).not.toContain('Interactive Client Content');
});

Testing the Post-Hydration (Client) State

Standard rendering in React Testing Library automatically triggers useEffect hooks, which simulates the post-hydration state.

import { render, screen } from '@testing-library/react';
import { ClientOnlyComponent } from './ClientOnlyComponent';

test('renders interactive content after hydration', () => {
  render(<ClientOnlyComponent />);
  
  // useEffect has run, so the client content should be visible
  expect(screen.getByText('Interactive Client Content')).toBeInTheDocument();
  expect(screen.queryByText('Loading server content...')).not.toBeInTheDocument();
});

Silencing Hydration Mismatch Warnings in Tests

If your tests generate unavoidable hydration console errors due to slight differences in the test environment configuration, you can mock the global console object to ignore them.

Add this configuration to your Jest setup file (jest.setup.js):

const originalError = console.error;

beforeAll(() => {
  console.error = (...args) => {
    if (/Warning: Did not expect server HTML to contain/.test(args[0])) {
      return;
    }
    if (/Warning: Text content did not match/.test(args[0])) {
      return;
    }
    originalError.call(console, ...args);
  };
});

afterAll(() => {
  console.error = originalError;
});