Back to Blog
Lesson 6 of the Intermediate React: Hooks, State & Data Patterns course
ReactState ManagementJune 25, 20264 min read

Building a useLocalStorage Hook: Persistence & State Management

Learn to build a robust useLocalStorage hook. Master generic storage, JSON serialization, and cross-tab synchronization to keep your React app state persistent.

ReactHookslocalStoragePersistenceState ManagementCustom Hooksjavascriptfrontend

Previously in this course, we explored Introduction to Custom Hooks to encapsulate and reuse logic across your application. While those lessons focused on abstracting component behavior, today we’re tackling a critical real-world requirement: data persistence.

By default, React state lives in memory. Refresh the page, and it’s gone. To build a production-grade dashboard, we need to bridge the gap between volatile component state and the browser's persistent storage. We’ll build a useLocalStorage hook that handles serialization, provides a clean API, and ensures our UI stays consistent even if the user opens multiple tabs.

Persistence with custom hooks

The goal is to create a hook that mimics the useState API but automatically syncs the value to localStorage. We need to handle three core challenges:

  1. JSON Serialization: localStorage only stores strings; we need to store objects and arrays.
  2. Initial State Logic: We shouldn't parse localStorage on every single render.
  3. Cross-tab Sync: When the user updates a setting in one tab, the others should reflect that change immediately.

Implementing the Hook

We’ll start by creating useLocalStorage.js. This hook will take a key and an initialValue.

JAVASCRIPT
import { useState, useEffect } from CE9178">'react';

export function useLocalStorage(key, initialValue) {
  // 1. Get initial value from storage or fallback to initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  // 2. Update storage whenever state changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

Handling Cross-Tab Synchronization

The basic implementation above works for a single tab, but it fails if the user has your dashboard open in two different windows. When localStorage changes in one tab, the other tabs don't automatically trigger a re-render.

We can solve this by listening to the storage event, which fires globally across the same origin.

JAVASCRIPT
useEffect(() => {
  const handleStorageChange = (e) => {
    if (e.key === key && e.newValue !== null) {
      setStoredValue(JSON.parse(e.newValue));
    }
  };

  window.addEventListener(CE9178">'storage', handleStorageChange);
  return () => window.removeEventListener(CE9178">'storage', handleStorageChange);
}, [key]);

Worked Example: Dashboard Theme Toggle

In our running project, let's use this hook to persist the user's dashboard theme preference.

JSX
import { useLocalStorage } from CE9178">'./hooks/useLocalStorage';

function DashboardSettings() {
  const [theme, setTheme] = useLocalStorage(CE9178">'dashboard-theme', CE9178">'light');

  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={() => setTheme(theme === CE9178">'light' ? CE9178">'dark' : CE9178">'light')}>
        Toggle Theme
      </button>
    </div>
  );
}

By using useLocalStorage here, the theme state survives page refreshes and stays in sync if the user toggles it in a different dashboard view. This is a massive improvement over standard useState for user preferences.

Hands-on Exercise

  1. Create the useLocalStorage hook file as shown above.
  2. Refactor your dashboard's "User Settings" component to use this hook instead of local state.
  3. Test it: Open your dashboard in two browser tabs. Toggle the setting in one tab and verify the other updates automatically.
  4. Edge Case: Try setting the value to null or an invalid JSON string in the browser DevTools Application tab and ensure your hook catches the error gracefully.

Common Pitfalls

  • JSON.parse failures: Always wrap your JSON.parse in a try/catch block. If a user manually edits their localStorage to an invalid value, your entire app could crash on mount.
  • Performance overhead: Don't use localStorage for high-frequency state updates (like tracking mouse coordinates). It involves synchronous disk I/O, which can cause jank. Reserve it for configuration or form data.
  • SSR Incompatibility: If you move to a framework like Next.js, window will be undefined on the server. Always check if (typeof window !== 'undefined') or only access storage inside useEffect.

Recap

We’ve moved beyond simple state management by bridging memory with persistent browser storage. We learned how to:

  • Encapsulate persistence logic using custom hooks.
  • Handle JSON serialization safely with error boundaries.
  • Synchronize state across multiple browser tabs using the storage event.

This pattern is a foundational piece of our dashboard project, ensuring that user preferences and cached UI states are preserved across sessions.

Up next: We'll dive into Complex State with useReducer, where we'll learn how to handle more intricate state transitions that go beyond simple key-value pairs.

Similar Posts