In react, hooks and javascript•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: Context Hell
To make this state accessible, we usually reach for a Context Provider. And that is the pain.
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 fundamentally redundant. We are copying data from one store (the browser) to another (React memory) 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 is the correct approach:
- 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 that our subscription model is working correctly—React is truly in sync with the browser's state.