Skip to main content

Problem

Need to persist UI state (theme, mode, preferences) across sessions without:
  • Adding database columns
  • Making backend API calls
  • Complicating component API

Solution: Context + useEffect + localStorage

This pattern is production-ready for any UI toggle, preference, or mode that should survive refresh/revisits.

Core Pattern

// Create context
const MyStateContext = React.createContext<MyState | null>(null);

export function MyStateProvider({ children }) {
  const [value, setValue] = useState<boolean>(defaultValue);
  const [mounted, setMounted] = useState(false);

  // Read from localStorage on mount (client-side only)
  useEffect(() => {
    const stored = localStorage.getItem("my-state-key");
    setValue(stored === null ? defaultValue : stored === "true");
    setMounted(true);
  }, []);

  const toggle = () => {
    const next = !value;
    setValue(next);
    localStorage.setItem("my-state-key", String(next));
  };

  if (!mounted) return children; // SSR safety

  return (
    <MyStateContext.Provider value={{ value, toggle, mounted }}>
      {children}
    </MyStateContext.Provider>
  );
}

export function useMyState() {
  const ctx = React.useContext(MyStateContext);
  if (!ctx) throw new Error("useMyState must be inside MyStateProvider");
  return ctx;
}

Why This Shape?

  1. Single useEffect: Runs once, reads localStorage, sets mounted. Prevents hydration mismatches.
  2. mounted Flag: Tells consumer when data is hydrated. Use it to avoid rendering before localStorage is read.
  3. Immediate State + Async Sync: setValue() is synchronous (updates immediately), localStorage write is fire-and-forget (no await).
  4. Null Check in Hook: Catches misconfigured provider nesting at dev time.

Consumer Code

function MyComponent() {
  const { value, toggle, mounted } = useMyState();

  if (!mounted) return null; // or a skeleton

  return (
    <button onClick={toggle}>
      Current: {value ? "on" : "off"}
    </button>
  );
}

Testing

Wrap test component in provider:
test("toggle persists to localStorage", () => {
  const { getByRole } = render(
    <MyStateProvider>
      <MyComponent />
    </MyStateProvider>
  );

  // Initial render (mounted=false, returns null)
  expect(getByRole("button")).toBeInTheDocument();

  // Click toggle
  fireEvent.click(getByRole("button"));

  // Verify localStorage
  expect(localStorage.getItem("my-state-key")).toBe("true");
});

Common Use Cases

PatternlocalStorage KeyTypeExample
Dark/Light Themeappearance-themeboolean"light" | "dark"
Compact Modeappearance-compactboolean"true" | "false"
Animations On/Offappearance-animationsboolean"true" | "false"
Language Preferencelanguagestring"en-US" | "es"
Sidebar Collapsedui-sidebar-collapsedboolean"true" | "false"

Why NOT Redux/Zustand?

For simple boolean/string toggles that persist to localStorage:
  • No action creators needed: Context + hook is lighter
  • No async middleware: localStorage is synchronous
  • No normalized state: Single value is the whole store
  • Fewer dependencies: React built-ins only
Use Redux/Zustand for:
  • Complex nested state structures
  • Actions with side effects
  • State shared across many routes
  • Undo/redo capabilities

Gotchas

Hydration Mismatch

Problem: SSR renders without localStorage, client hydrates with different value. Fix: Always use mounted flag; don’t render until hydrated.
if (!mounted) return <LoadingSkeleton />; // Safe fallback

localStorage Called in Component Body

Problem:
function Bad() {
  const value = localStorage.getItem("key"); // ❌ Runs every render
  // ...
}
Fix: Move to useEffect:
function Good() {
  const [value, setValue] = useState(null);
  useEffect(() => {
    setValue(localStorage.getItem("key")); // ✅ Runs once on mount
  }, []);
  // ...
}

Forgetting Provider Wrapper

Problem: Component uses hook outside provider → throws “undefined context” error. Fix: Ensure provider wraps component in tree. Use root/providers.tsx in Next.js projects.

Not Handling JSON Values

Problem: Storing arrays/objects as strings without serialization. Fix:
// Writing
localStorage.setItem("config", JSON.stringify({ theme: "dark", font: 14 }));

// Reading
const config = JSON.parse(localStorage.getItem("config") || "{}");

Performance Notes

  • localStorage reads/writes are synchronous (not async)
  • Reading is ~1ms, writing is ~0.5ms on most browsers
  • Safe to call on every toggle (no performance risk)
  • If storing large objects (>1MB), consider IndexedDB instead

Across Codebases

This pattern appears in:
  • meow-web (cat mode, animations)
  • whiskers-landing (theme preference)
  • Any Next.js app with client-side state
  • Mobile apps via AsyncStorage equivalent
Standardize the shape across projects for consistency.