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