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