[EN/React] What is useSyncExternalStore in React

Tags
React
Created
June 10, 2024

Background

Problem

While working on the KanbanWave project, I encountered an issue where changes made to data stored in an external storage (e.g., localStorage) were not being immediately reflected in the UI. (I mean, I had to refresh the page to see the updates ,,,) I needed a way to synchronize the state between components and external storage.

Exploring Solutions

To solve this problem, I explored various approaches. A developer friend suggested, "How about trying useSyncExternalStore?" Honestly, I’d never heard of it before, lol. After checking the official documentation, I learned that it was a React hook designed to synchronize the state between components and external stores.

It felt like a perfect solution, especially since I was looking for a simple way to pass information between components without needing a complex state management library. ✨

In particular, useSyncExternalStore seems even more useful because it effectively addresses the Tearing issue introduced with Concurrent Mode in React 18. For more details on this, you can refer to the article on [React] Tearing issue with Concurrent Mode in React 18.

image

useSyncExternalStore

useSyncExternalStore is a React hook introduced in React 18 that allows a component to subscribe to external state changes and re-render whenever that state changes.

Below is an example that demonstrates how to use the useSyncExternalStore hook to subscribe to and read from an external data store (todoStore).

import { useSyncExternalStore } from 'react';
import { todosStore } from './todoStore.js';

function TodosApp() {
  const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot);
  // ...
}
  • todoStore: This represent the external data store, such as an object managing the current state of a to-do list(todos). It provides methods like subscribe, getSnapshot for components to access and subscribe to this data.
  • useSyncExternalStore: Using the useSyncExternalStore hook, the TodosApp component subscribes to the state of todoStore and re-renders the component every time the state changes.
  • todos: This variable holds the current state as returned by todoStore.getSnapshot. It’s used to render the list of tasks in the component.

As you can see from the example, several important concepts emerge. I’ve broken down the key ideas related to the useSyncExternalStore hook and explained how they work. Let’s also document the steps I followed when implementing it.

Concepts

subscribe

The component request to be notified when the external store’s state changes. By calling subscribe, the component tells the store, "Let me know when that data changes!" The component passes its callback function, which the external store saves and later invokes whenever the state changes.

📢 Component’s Perspective
📢 External Store Perspective
I need to know when the external store’s data changes so I can re-render the UI. So, I’ll subscribe to the external store. That way, the external store will call my callback whenever its state changes, and I can re-render with the updated data!
I provide state to multiple components. When a component subscribes to my state, I save the callback function it provides. Then, whenever the state changes, I’ll call all the saved callbacks to notify the components that the state has changed.

listeners

The callback functions registered with subscribe are stored in listeners. listeners is a list (array) of callback functions that will be notified of changes in the external store’s state. In simpler terms, these are like friends saying, "When the external store’s state changes, I'll respond!" Whenever the state changes, these listeners are called to pass the updated information to the components.

📢 Component’s Perspective
📢 External Store Perspective
Since I need to be notified immediately when the state changes, I'll pass my callback function to subscribe for the external store. The external store will then register my callback in the listeners, and when the state changes, this callback will be executed to automatically update the screen.
I'm managing the state, and whenever the state changes, I need to notify all subscribed components. So, I'll save the callback functions provided by each component in a listeners list, and when the state changes, I'll execute all the callbacks in this list at once to inform all the components.

emitChanges

When the external store's state changes, the emitChanges function gets called. This function is like the messenger, shouting, "The data has changed! Time to send out notifications!" It updates the state and calls all the listeners registered in listeners.

📢 Component’s Perspective
📢 External Store Perspective
When the state of an external store changes, it needs to notify immediately so that the screen can be re-rendered. This role is handled by emitChanges, which gets executed every time the state changes and sends notifications to all components, including me.
When the state changes, my role is to notify all components. emitChanges helps with this by calling the callback functions stored in listeners, allowing each component to reflect the new data.

snapshot

When a state change occurs, we need to check the current state to reflect the new data in the component. A snapshot is an object that says, "Show me what’s currently inside the external store!" Just like taking a photo, it provides the component with the current data in external store.

getSnapshot

When a component calls the getSnapshot function, it is essentially saying, "Tell me what's in external storage right now!" Right. Through getSnapshot, it retrieves the snapshot. A component can use the returned snapshot to obtain the current state information.

This process helps understand the complete flow: how a component subscribes to the state, detects state changes, and reflects the updated state.

Code Example

Here’s how I implemented this in the KanbanWave project to manage data in external storage and synchronize it with React components.

Creating a Custom Store

I created a custom store to manage the external storage. This store allows components to subscribe to its state and reflect changes when the state is updated.

export const makeKanbanwaveStore = (storage: KanbanwaveStorage) => {
  let snapshot = {
    getBoards: wrap(storage.getBoards),
    getBoardContent: wrap(storage.getBoardContent),
    getCard: wrap(storage.getCard)
  };
  let listeners: Array<() => void> = [];
  • The snapshot object wraps the external storage methods (getBoards, getBoardContent, getCard) to store the current state.
  • A listener function is added to the listeners array when a component subscribes to the store. This array stores listener functions that will be called when the state chages.
const emitChanges = (changes: Array<keyof typeof snapshot>) => {
  snapshot = { ...snapshot };
  changes.forEach(key => {
    const fn = wrap(storage[key]) as (typeof snapshot)[typeof key];
    snapshot[key] = fn as any;
  });
  for (let listener of listeners) {
    listener();
  }
};
  • emitChanges is a function that is called when the data in the external storage changes. This function updates the snapshot values corresponding to the keys included in the given changes array and notifies all listeners to reflect the changes.
return {
  subscribe(listener: () => void) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(it => it !== listener);
    };
  },
  getSnapshot() {
    return snapshot;
  },
  async createBoard(...args: Parameters<typeof storage.createBoard>) {
    await storage.createBoard(...args);
    emitChanges(['getBoards']);
  },
  {
    ...
  },
  async deleteCard(...args: Parameters<typeof storage.deleteCard>) {
    await storage.deleteCard(...args);
    emitChanges(['getBoardContent', 'getCard']);
  },
};
};
  • subscribe(listener): Register a listener for state changes. When a component subscribes, the listener is added to the listeners array, and it returns a function to unsubscribe the listener.
  • getSnapshot: Returns the current snapshot, allowing subscribed components to retrieves the latest state.
  • createBoard: Create a new board in the external storage and calls emitChanges to update the state.

Using the Store in React Component

Now, We can use the useSyncExternalStore hook to subscribe to the store and automatically update the UI when the data changes.

export const useKanbanwaveStore = () => {
  const context = useContext(KanbanStorageContext);
  const store = context.store;
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
  • We retrieve the store from KanbanStorageContext and sets up the component to subscribe to the store using useSyncExternalStore. It provides store.subscribe and store.getSnapshot to handle state subscriptions and to get the latest snapshot.
  return useMemo(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { subscribe, getSnapshot, ...methods } = store;
    return {
      ...snapshot,
      ...methods
    };
  }, [snapshot, store]);
};
  • useMemo: We use this hook to memoize the store’s methods and the current snapshot to avoid unnecessary re-renders.

Setting the Provider

Finally, I’ve set up an environment where the useKanbanwaveStore hook can function using KanbanStorageProvider.

type KanbanStorageProviderProps = {
  children?: ReactNode;
  storage: KanbanwaveStorage;
};

const KanbanStorageProvider = ({ storage, children }: KanbanStorageProviderProps) => {
  const store = useMemo(() => makeKanbanwaveStore(storage), [storage]);
  const contextValue = useMemo(() => ({ store }), [store]);
  
  return (
    <KanbanStorageContext.Provider value={contextValue}>
      {children}
    </KanbanStorageContext.Provider>
  );
};
  • KanbanStorageProviderProps: storage is an external storage instance.
  • useMemo: useMemo is used to prevent unnecessary re-calls of the makeKanbanwaveStore function. This hook ensures that, as long as storage does not change, the same store instance is reused.
  • KanbanStorageContext.Provider: The Context API is used to set up the KanbanStorageContext, allowing all components within it to access this store.

Conclusion

In this post, I explained how to use useSyncExternalStore to manage and synchronize state with external storage in the KanbanWave project. This hook was perfect for synchronizing data and ensuring that the UI re-rendered only when necessary. I hope this breakdown helps clarify how to use this React hook in your own projects!