January 15, 2026•3 min read
We often talk about "Single Source of Truth" in React, yet we constantly violate it when working with localStorage.
If you have a theme preference stored in localStorage, that is the store. It's right there in the browser. But what do we usually do? We create a React state to duplicate that value.
The Anti-Pattern: Duplicating State
To make this value accessible, we usually reach for a Context Provider, and that adds boilerplate.
You have to wrap your whole app in a Provider, create the context, write a hook to consume it... all just to share a simple string that is already globally available in the browser window.
// ❌ All this boilerplate just to read a string?
const ThemeContext = createContext('light');
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => localStorage.getItem('theme'));
useEffect(() => {
// Manually keeping state in sync...
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};This works, but it's redundant. We copy data from one store (the browser) to another (React state) just so React can react to it.
The Solution: useSyncExternalStore
React 18 introduced useSyncExternalStore, which is often misunderstood as a "library author only" hook. It's actually the perfect tool for this exact problem.
It allows React to treat localStorage as the store itself, rather than just a place we persist to. No Context, no Providers, just a hook.
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('storage', callback);
// 🗣️ The 'storage' event is the magic that syncs across tabs!
return () => window.removeEventListener('storage', callback);
}
function getSnapshot() {
// ✅ Read directly from the source
return localStorage.getItem('theme');
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot);
return theme || 'light';
}Why this works better:
- Single Source of Truth: There is no
useState. The value inlocalStorageis the only value that matters. React reads it when it needs to render. - No Context Boilerplate: You don't need to wrap your app in a provider. Just call the hook wherever you need the theme.
- No Synchronization Logic: We don't need
useEffectto push changes back and forth. When we want to change the theme, we just write tolocalStoragedirectly, and React updates.
Bonus: Free Cross-Tab Sync
Because the subscribe function listens to the window's storage event, we get a nice side effect: if the user changes the theme in another tab, this tab updates instantly. It's not the main goal, but it proves the subscription model works and React stays in sync with browser state.
How do you handle localStorage state in React today? Share your approach with me on X @yhabibf.