Skip to main content
Stop Duplicating State: localStorage IS the Store

January 15, 20263 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.

ThemeContext.tsx
// ❌ 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.

useTheme.ts
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:

  1. Single Source of Truth: There is no useState. The value in localStorage is the only value that matters. React reads it when it needs to render.
  2. No Context Boilerplate: You don't need to wrap your app in a provider. Just call the hook wherever you need the theme.
  3. No Synchronization Logic: We don't need useEffect to push changes back and forth. When we want to change the theme, we just write to localStorage directly, 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.