{ ILoveJS }

useLocalStorage hook with SSR safety

typescript

A fully typed React hook for persisting state to localStorage with automatic JSON serialization, SSR compatibility, and real-time synchronization across browser tabs.

reacthookslocalStoragessr

Code

typescript
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';

type SetValue<T> = T | ((prevValue: T) => T);

interface UseLocalStorageOptions<T> {
  serializer?: (value: T) => string;
  deserializer?: (value: string) => T;
}

function getServerSnapshot<T>(defaultValue: T): () => T {
  return () => defaultValue;
}

function dispatchStorageEvent(key: string, newValue: string | null): void {
  window.dispatchEvent(
    new StorageEvent('storage', {
      key,
      newValue,
      storageArea: localStorage,
    })
  );
}

export function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  options: UseLocalStorageOptions<T> = {}
): [T, (value: SetValue<T>) => void, () => void] {
  const {
    serializer = JSON.stringify,
    deserializer = JSON.parse,
  } = options;

  const getSnapshot = useCallback((): T => {
    try {
      const item = localStorage.getItem(key);
      return item !== null ? deserializer(item) : defaultValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return defaultValue;
    }
  }, [key, defaultValue, deserializer]);

  const subscribe = useCallback(
    (callback: () => void): (() => void) => {
      const handleStorageChange = (event: StorageEvent): void => {
        if (event.key === key || event.key === null) {
          callback();
        }
      };

      window.addEventListener('storage', handleStorageChange);
      window.addEventListener('local-storage', handleStorageChange as EventListener);

      return () => {
        window.removeEventListener('storage', handleStorageChange);
        window.removeEventListener('local-storage', handleStorageChange as EventListener);
      };
    },
    [key]
  );

  const storedValue = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot(defaultValue)
  );

  const setValue = useCallback(
    (value: SetValue<T>): void => {
      try {
        const currentValue = getSnapshot();
        const newValue = value instanceof Function ? value(currentValue) : value;
        const serializedValue = serializer(newValue);
        
        localStorage.setItem(key, serializedValue);
        dispatchStorageEvent(key, serializedValue);
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error);
      }
    },
    [key, serializer, getSnapshot]
  );

  const removeValue = useCallback((): void => {
    try {
      localStorage.removeItem(key);
      dispatchStorageEvent(key, null);
    } catch (error) {
      console.warn(`Error removing localStorage key "${key}":`, error);
    }
  }, [key]);

  return [storedValue, setValue, removeValue];
}

// Example usage component
export function ExampleComponent(): JSX.Element {
  const [user, setUser, removeUser] = useLocalStorage<{ name: string; age: number } | null>(
    'user-data',
    null
  );

  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

  const [count, setCount] = useLocalStorage<number>('counter', 0);

  return (
    <div>
      <h2>User: {user?.name ?? 'Not logged in'}</h2>
      <button onClick={() => setUser({ name: 'John', age: 30 })}>Login</button>
      <button onClick={removeUser}>Logout</button>
      
      <h2>Theme: {theme}</h2>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
      
      <h2>Count: {count}</h2>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

How It Works

This useLocalStorage hook leverages React 18's useSyncExternalStore API, which is specifically designed for subscribing to external data sources like localStorage. This approach is superior to the traditional useState + useEffect pattern because it properly handles concurrent rendering and avoids tearing issues where different parts of your UI could show inconsistent values during a render.

The SSR safety is achieved through the third argument of useSyncExternalStore - the getServerSnapshot function. During server-side rendering, React calls this function instead of getSnapshot since localStorage doesn't exist on the server. By returning the default value during SSR, we avoid any 'window is not defined' errors while ensuring the initial server render produces valid HTML. The hook automatically hydrates with the actual localStorage value on the client.

Cross-tab synchronization works through two mechanisms. The native 'storage' event fires automatically when localStorage changes in another tab, but crucially, it does NOT fire in the same tab that made the change. To handle same-tab updates and trigger re-renders, we dispatch a custom StorageEvent after each setValue or removeValue call. The subscribe function listens to both events, ensuring all components using the same key stay synchronized whether the change originated locally or from another tab.

The generic typing with SetValue allows the setter to accept either a direct value or a function that receives the previous value, mirroring React's useState API. The options object supports custom serializers for cases where JSON isn't suitable - for example, storing Date objects that need special handling, or using a more efficient binary format for large data structures.

Use this hook whenever you need persistent state that survives page refreshes. It's ideal for user preferences, authentication tokens, shopping carts, or form drafts. Avoid it for sensitive data (localStorage is vulnerable to XSS attacks), frequently changing data (localStorage is synchronous and can block the main thread), or data larger than 5MB (the typical browser limit). For server-state that needs persistence, consider combining this with a proper caching solution like React Query.