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.
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 likesubscribe
,getSnapshot
for components to access and subscribe to this data.useSyncExternalStore
: Using theuseSyncExternalStore
hook, theTodosApp
component subscribes to the state oftodoStore
and re-renders the component every time the state changes.todos
: This variable holds the current state as returned bytodoStore.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 thesnapshot
values corresponding to the keys included in the givenchanges
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 thelisteners
array, and it returns a function to unsubscribe the listener.getSnapshot
: Returns the currentsnapshot
, allowing subscribed components to retrieves the latest state.createBoard
: Create a new board in the external storage and callsemitChanges
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
fromKanbanStorageContext
and sets up the component to subscribe to the store usinguseSyncExternalStore
. It providesstore.subscribe
andstore.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 themakeKanbanwaveStore
function. This hook ensures that, as long asstorage
does not change, the same store instance is reused.KanbanStorageContext.Provider
: The Context API is used to set up theKanbanStorageContext
, 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!