How to Test React Reconciliation

Testing reconciliation in React involves verifying that the virtual DOM efficiently updates the real DOM without losing component state or causing unnecessary re-renders. This article explains how React’s reconciliation algorithm works under the hood and provides practical strategies, code examples, and tools to test that your components update, reorder, and render as efficiently as possible.

Understanding Reconciliation in Testing

Reconciliation is React’s process of diffing two virtual DOM trees to determine which parts of the actual DOM need to be updated. While you do not need to test React’s internal diffing algorithm itself, you must test the consequences of reconciliation in your application.

When testing reconciliation, you are primarily asserting two behaviors: 1. State Preservation: Ensuring components maintain their internal state during updates (especially when list items are reordered using key props). 2. Render Optimization: Ensuring components do not re-render unnecessarily when props or state do not change.

Test Case 1: Verifying State Retention with Keys

React uses the key prop to identify which items in a list have changed, been added, or been removed. If keys are unstable (like using array indexes for a dynamic list), React may recreate DOM elements instead of moving them, destroying their internal state.

You can test this behavior using React Testing Library by asserting that an active element (like an input with focus) retains its focus after the list is reordered.

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

const DynamicList = () => {
  const [items, setItems] = useState([
    { id: '1', val: 'First' },
    { id: '2', val: 'Second' }
  ]);

  const reverseList = () => setItems([...items].reverse());

  return (
    <div>
      <button onClick={reverseList}>Reverse</button>
      {items.map((item) => (
        <input key={item.id} placeholder={item.val} defaultValue="" />
      ))}
    </div>
  );
};

test('should retain input focus and value during reconciliation', async () => {
  render(<DynamicList />);
  
  const firstInput = screen.getByPlaceholderText('First');
  const secondInput = screen.getByPlaceholderText('Second');

  // Focus the first input and type into it
  await userEvent.type(firstInput, 'Hello');
  expect(firstInput).toHaveFocus();

  // Reverse the list
  await userEvent.click(screen.getByRole('button', { name: /reverse/i }));

  // Assert that the input with "First" still has focus and retains its value
  expect(firstInput).toHaveFocus();
  expect(firstInput).toHaveValue('Hello');
});

If you were to change key={item.id} to key={index}, React would reuse the DOM nodes based on index, causing the second input to incorrectly inherit the focus and typed value.

Test Case 2: Asserting and Preventing Unnecessary Re-renders

To verify that your reconciliation optimization techniques (such as React.memo, useMemo, or useCallback) are working, you can track component render counts.

The most straightforward way to test this in a unit test is by passing a Jest spy function inside the render cycle of a child component.

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

const MemoizedChild = React.memo(({ onRender, value }) => {
  onRender();
  return <div>{value}</div>;
});

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

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <MemoizedChild value={text} onRender={onRenderSpy} />
    </div>
  );
};

const onRenderSpy = jest.fn();

beforeEach(() => {
  onRenderSpy.mockClear();
});

test('memoized child does not re-render when unrelated parent state changes', async () => {
  render(<Parent />);

  // Initial render calls the spy once
  expect(onRenderSpy).toHaveBeenCalledTimes(1);

  // Trigger state change in parent that does not affect child props
  await userEvent.click(screen.getByRole('button', { name: /increment count/i }));

  // Child should not re-render during reconciliation due to React.memo
  expect(onRenderSpy).toHaveBeenCalledTimes(1);
});

Advanced Testing with React Profiler

For integration or performance testing, you can wrap your component tree in React’s <Profiler> component. This allows you to programmatically measure how often a subtree renders and identify reconciliation bottlenecks.

import { render } from '@testing-library/react';
import React, { Profiler } from 'react';

const onRenderCallback = jest.fn();

test('measures reconciliation performance using Profiler', () => {
  render(
    <Profiler id="App" onRender={onRenderCallback}>
      <MyComponent />
    </Profiler>
  );

  // The callback provides detailed metadata about the render phase
  expect(onRenderCallback).toHaveBeenCalledWith(
    expect.any(String), // phase (mount or update)
    expect.any(String), // actual duration
    expect.any(Number), // base duration
    expect.any(Number), // start time
    expect.any(Number)  // commit time
  );
});