How to Test useSyncExternalStore in React

Testing the useSyncExternalStore hook in React requires verifying that your components correctly subscribe to external data stores, update when the store changes, and unsubscribe when they unmount. This article provides a straightforward guide on how to test components utilizing useSyncExternalStore using React Testing Library and Jest or Vitest, covering both internal component triggers and external store updates.

The Store and Component Setup

To demonstrate testing, we will use a basic external store and a component that subscribes to it.

Here is the external store (store.js):

let state = { count: 0 };
const listeners = new Set();

export const store = {
  subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  getSnapshot() {
    return state;
  },
  increment() {
    state = { count: state.count + 1 };
    listeners.forEach((listener) => listener());
  },
  reset() {
    state = { count: 0 };
    listeners.forEach((listener) => listener());
  }
};

Here is the React component using useSyncExternalStore (Counter.js):

import { useSyncExternalStore } from 'react';
import { store } from './store';

export function Counter() {
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);

  return (
    <div>
      <span data-testid="count">{state.count}</span>
      <button onClick={store.increment}>Increment</button>
    </div>
  );
}

Writing the Tests

When testing useSyncExternalStore, you need to handle two scenarios: 1. State updates triggered by UI interactions (like clicking a button). 2. State updates triggered directly by the external store outside of the React render cycle.

Step 1: Set Up the Test Environment

First, import the required utilities from React Testing Library and your store/component. Since the store holds global state, reset it before each test to ensure test isolation.

import { render, screen, fireEvent, act } from '@testing-library/react';
import { Counter } from './Counter';
import { store } from './store';

beforeEach(() => {
  store.reset();
});

Step 2: Test Initial Render and UI Interactions

This test ensures the component correctly reads the initial state from the store and updates when the UI triggers a store change.

test('should render initial state and update on UI interaction', () => {
  render(<Counter />);
  
  const countElement = screen.getByTestId('count');
  expect(countElement).toHaveTextContent('0');

  // Trigger increment via UI button
  const button = screen.getByRole('button', { name: /increment/i });
  fireEvent.click(button);

  expect(countElement).toHaveTextContent('1');
});

Step 3: Test External Store Updates (Crucial for useSyncExternalStore)

Because useSyncExternalStore is designed to sync with stores that can be updated from outside of React’s context, you must test what happens when the store updates independently.

To do this, use React’s act utility to wrap the external store mutation. This forces React to flush any pending microtasks and commit the changes to the DOM.

test('should update the UI when the external store updates directly', () => {
  render(<Counter />);
  
  const countElement = screen.getByTestId('count');
  expect(countElement).toHaveTextContent('0');

  // Update the store directly outside of React
  act(() => {
    store.increment();
  });

  expect(countElement).toHaveTextContent('1');
});

Step 4: Verify Subscription Cleanup

To ensure that the component unsubscribes when it unmounts (preventing memory leaks), you can spy on the subscribe function.

test('should unsubscribe from the store when unmounted', () => {
  const unsubscribeSpy = jest.fn();
  
  // Temporarily spy on store.subscribe
  const originalSubscribe = store.subscribe;
  jest.spyOn(store, 'subscribe').mockImplementation((listener) => {
    const unsubscribe = originalSubscribe(listener);
    return () => {
      unsubscribeSpy();
      unsubscribe();
    };
  });

  const { unmount } = render(<Counter />);
  
  unmount();

  expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
  
  store.subscribe.mockRestore();
});