How to Test Lifting State Up in React

Testing the “lifting state up” pattern in React requires verifying that a parent component successfully coordinates state between its child components. To do this effectively, you should write integration tests that render the parent component, simulate user interactions in one child component, and assert that the resulting state changes are correctly reflected in another child component. This guide demonstrates how to test this pattern using React Testing Library and Jest.

The Strategy: Component Integration Testing

When state is lifted, the individual child components become “controlled” components that rely on props passed from the parent. Testing these children in isolation using mocked functions is useful, but the most reliable way to test lifted state is to test the parent and children together as a single unit. This ensures that the state flow actually works in runtime.

Example Scenario

Consider a parent component, TemperatureCalculator, which holds the state. It has two child components: 1. TemperatureInput: A text input where the user types a temperature. 2. BoilingVerdict: A display component that shows whether the water would boil at that temperature.

Step-by-Step Testing Implementation

1. The Components Under Test

Here is the implementation of the components we want to test:

// TemperatureInput.js
export function TemperatureInput({ temperature, onTemperatureChange }) {
  return (
    <fieldset>
      <legend>Enter temperature in Celsius:</legend>
      <input
        value={temperature}
        onChange={(e) => onTemperatureChange(e.target.value)}
        placeholder="Enter temp"
      />
    </fieldset>
  );
}

// BoilingVerdict.js
export function BoilingVerdict({ celsius }) {
  if (parseFloat(celsius) >= 100) {
    return <p>The water would boil.</p>;
  }
  return <p>The water would not boil.</p>;
}

// TemperatureCalculator.js (Parent holding the lifted state)
import { useState } from 'react';
import { TemperatureInput } from './TemperatureInput';
import { BoilingVerdict } from './BoilingVerdict';

export default function TemperatureCalculator() {
  const [temperature, setTemperature] = useState('');

  return (
    <div>
      <TemperatureInput 
        temperature={temperature} 
        onTemperatureChange={setTemperature} 
      />
      <BoilingVerdict celsius={temperature} />
    </div>
  );
}

2. Writing the Integration Test

To test the lifted state, render the TemperatureCalculator parent component. Use user-event to type into the input field (handled by TemperatureInput), and assert that the text in BoilingVerdict updates accordingly.

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

describe('TemperatureCalculator - Lifted State Integration', () => {
  test('updates boiling verdict when temperature input changes', async () => {
    // 1. Render the parent component
    render(<TemperatureCalculator />);

    // 2. Locate the input field from the child component
    const inputElement = screen.getByPlaceholderText(/enter temp/i);
    
    // Check initial state
    expect(screen.getByText(/the water would not boil/i)).toBeInTheDocument();

    // 3. Simulate user typing a boiling temperature
    await userEvent.type(inputElement, '105');

    // 4. Assert that the sibling child component has updated
    expect(screen.getByText(/the water would boil/i)).toBeInTheDocument();
    expect(screen.queryByText(/the water would not boil/i)).not.toBeInTheDocument();
  });
});

3. Testing Child Components in Isolation (Optional)

While integration testing is the priority, you should also write brief unit tests for the child components to ensure they behave correctly when receiving props.

For the input component, verify it calls its callback prop:

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

test('calls onTemperatureChange when user types', async () => {
  const handleChanceMock = jest.fn();
  render(
    <TemperatureInput 
      temperature="" 
      onTemperatureChange={handleChanceMock} 
    />
  );

  const inputElement = screen.getByPlaceholderText(/enter temp/i);
  await userEvent.type(inputElement, '5');

  expect(handleChanceMock).toHaveBeenCalledWith('5');
});

For the display component, verify it outputs the correct text based on the prop:

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

test('renders correct verdict for boiling temperature', () => {
  render(<BoilingVerdict celsius="100" />);
  expect(screen.getByText(/the water would boil/i)).toBeInTheDocument();
});