Local storage is a good place used commonly to store data (not auth tokens though!) that needs to be persisted between sessions.
You can conveniently store user preferences like a collapsed or expanded sidebar in local storage. However, updates won’t sync across multiple tabs. To solve this, use the
useSyncExternalStore
hook in React to ensure consistent data across tabs.
From the official docs:
useSyncExternalStore
is a React Hook that lets you subscribe to an external store.
In our context, the external store refers to local storage. useSyncExternalStore allows us to bridge the gap between React and local storage by subscribing a component to local storage.
Example with useState
+ useEffect
Let’s first see an example of a bad practice that does not work properly ❌:
Code that’s used in the example above:
import React from "react";
type SidebarState = "collapsed" | "expanded";
const get = () => localStorage.getItem("sidebar") as SidebarState;
const set = (value: SidebarState) => localStorage.setItem("sidebar", value);
if (!get()) {
set("collapsed");
}
function App() {
const [sidebarState, setSidebarState] = React.useState<SidebarState>(get());
React.useEffect(() => {
set(sidebarState);
}, [sidebarState]);
const handleToggle = () =>
setSidebarState(sidebarState === "collapsed" ? "expanded" : "collapsed");
return (
<>
<p>
The sidebar is{" "}
<span style={{ color: sidebarState === "collapsed" ? "red" : "green" }}>
{sidebarState}
</span>
</p>
<button onClick={handleToggle}>Toggle State</button>
</>
);
}
- We only use local storage as an initial state for React’s useState. This is needed to achieve reactivity, otherwise React won’t update the UI (re-render) on direct updates to local storage.
-
useState
means the state is coupled with this component instance—which means it won’t sync across tabs. - Lastly,
useState
means we need to sync local storage with changes, so that the next time the app is loaded, we get the latest state that was set.
Example with useSyncExternalStore
The correct way to do it 💪
Code that’s used in the example above:
import React from "react";
type SidebarState = "collapsed" | "expanded";
function setSidebarState(newValue: SidebarState) {
window.localStorage.setItem("sidebar", newValue);
// On localStoage.setItem, the storage event is only triggered on other tabs and windows.
// So we manually dispatch a storage event to trigger the subscribe function on the current window as well.
window.dispatchEvent(
new StorageEvent("storage", { key: "sidebar", newValue })
);
}
const store = {
getSnapshot: () => localStorage.getItem("sidebar") as SidebarState,
subscribe: (listener: () => void) => {
window.addEventListener("storage", listener);
return () => void window.removeEventListener("storage", listener);
},
};
// Set the initial value.
if (!store.getSnapshot()) {
localStorage.setItem("sidebar", "collapsed" satisfies SidebarState);
}
function App() {
const sidebarState = React.useSyncExternalStore(
store.subscribe,
store.getSnapshot
);
const handleToggle = () => {
setSidebarState(sidebarState === "expanded" ? "collapsed" : "expanded");
};
return (
<>
<p>
The sidebar is
<span style={{ color: sidebarState === "collapsed" ? "red" : "green" }}>
{sidebarState}
</span>
</p>
<button onClick={handleToggle}>Toggle State</button>
</>
);
}
useSyncExternalStore
accepts two required arguments:
- The
subscribe
function should subscribe to the store and return a function that unsubscribes. Thelistener
argument in this function automatically listens to storage events and re-renders the component on changes. - The
getSnapshot
function should read a snapshot of the data from the store. To keep things simple, you should avoid returning immutable data (e.g. objects) since they are different on everygetSnapshot
invocation and will cause infinite re-renders. If you need to, you should cache the return value ofgetSnapshot
.
These two functions connect the data persisted in local storage to React, and allow reactivity across tabs and windows.
Bonus: Extract the store to a custom hook
Finally, you can extract the logic to a custom hook:
import React from "react";
type SidebarState = "collapsed" | "expanded";
function useSidebarState() {
const setSidebarState = (newValue: SidebarState) => {
window.localStorage.setItem("sidebar", newValue);
window.dispatchEvent(
new StorageEvent("storage", { key: "sidebar", newValue })
);
};
const getSnapshot = () => localStorage.getItem("sidebar") as SidebarState;
const subscribe = (listener: () => void) => {
window.addEventListener("storage", listener);
return () => void window.removeEventListener("storage", listener);
};
const store = React.useSyncExternalStore(subscribe, getSnapshot);
return [store, setSidebarState] as const;
}
Summary
Today, we learned that the combination of useState and useEffect is not the ideal way to manage the state using data that lives in local storage. We can use local storage as an external store that communicates with React using useSyncExternalStore.
Let’s Connect
You can connect with me using the following links:
If you wish to see more content on full-stack engineering, make sure to like and follow to show your support! Thank you!
Top comments (0)