Skip to content

┌─< lukebayliss.com >─┐

/snippets/local-storage-hook

Type-Safe useLocalStorage Hook

A React hook for managing localStorage with TypeScript type safety, automatic JSON parsing, and sync across tabs.

  • react
  • hooks
  • storage

A production-ready useLocalStorage hook that handles JSON serialization, type safety, and cross-tab synchronization.

import { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';

type SetValue<T> = Dispatch<SetStateAction<T>>;

function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, SetValue<T>, () => void] {
  // State to store our value
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // Return a wrapped version of useState's setter function that persists to localStorage
  const setValue: SetValue<T> = useCallback((value) => {
    try {
      // Allow value to be a function (same API as useState)
      const valueToStore = value instanceof Function ? value(storedValue) : value;

      setStoredValue(valueToStore);

      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));

        // Dispatch custom event for cross-tab sync
        window.dispatchEvent(new CustomEvent('local-storage', {
          detail: { key, value: valueToStore }
        }));
      }
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  // Remove item from localStorage
  const removeValue = useCallback(() => {
    try {
      setStoredValue(initialValue);

      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(key);
        window.dispatchEvent(new CustomEvent('local-storage', {
          detail: { key, value: undefined }
        }));
      }
    } catch (error) {
      console.error(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, initialValue]);

  // Sync state across tabs
  useEffect(() => {
    const handleStorageChange = (e: StorageEvent | CustomEvent) => {
      if (e instanceof StorageEvent) {
        // Handle native storage event (from other tabs)
        if (e.key === key && e.newValue !== null) {
          try {
            setStoredValue(JSON.parse(e.newValue));
          } catch (error) {
            console.error(`Error parsing localStorage value for key "${key}":`, error);
          }
        } else if (e.key === key && e.newValue === null) {
          setStoredValue(initialValue);
        }
      } else {
        // Handle custom event (from same tab)
        const detail = (e as CustomEvent).detail;
        if (detail.key === key) {
          setStoredValue(detail.value !== undefined ? detail.value : initialValue);
        }
      }
    };

    // Listen for native storage events (cross-tab)
    window.addEventListener('storage', handleStorageChange as EventListener);

    // Listen for custom events (same tab)
    window.addEventListener('local-storage', handleStorageChange as EventListener);

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

  return [storedValue, setValue, removeValue];
}

// Usage Examples:

// Basic string storage
function ThemeToggle() {
  const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current theme: {theme}
    </button>
  );
}

// Complex object storage
interface UserPreferences {
  notifications: boolean;
  language: string;
  fontSize: number;
}

function Settings() {
  const [prefs, setPrefs] = useLocalStorage<UserPreferences>('user-prefs', {
    notifications: true,
    language: 'en',
    fontSize: 16
  });

  const toggleNotifications = () => {
    setPrefs(prev => ({
      ...prev,
      notifications: !prev.notifications
    }));
  };

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={prefs.notifications}
          onChange={toggleNotifications}
        />
        Enable notifications
      </label>

      <select
        value={prefs.language}
        onChange={(e) => setPrefs({ ...prefs, language: e.target.value })}
      >
        <option value="en">English</option>
        <option value="es">Spanish</option>
      </select>
    </div>
  );
}

// Array storage with type safety
function TodoList() {
  const [todos, setTodos] = useLocalStorage<string[]>('todos', []);

  const addTodo = (text: string) => {
    setTodos(prev => [...prev, text]);
  };

  const removeTodo = (index: number) => {
    setTodos(prev => prev.filter((_, i) => i !== index));
  };

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          {todo}
          <button onClick={() => removeTodo(index)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

// Server-side rendering safe
function SafeComponent() {
  const [count, setCount] = useLocalStorage('count', 0);

  // SSR: returns initialValue (0)
  // Client: returns stored value or initialValue
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

Features

  • Type Safety: Full TypeScript support for stored values
  • SSR Compatible: Works with Next.js and other SSR frameworks
  • Cross-Tab Sync: Changes in one tab reflect in other tabs
  • useState API: Familiar API with functional updates support
  • Error Handling: Graceful fallback when localStorage is unavailable
  • Cleanup Function: Remove stored values when needed
  • JSON Serialization: Automatic serialization/deserialization

Gotchas

  • Storage Quota: Most browsers limit localStorage to 5-10MB. Store large data elsewhere.
  • Synchronous API: localStorage blocks the main thread. For large data, consider IndexedDB.
  • Same-Origin Only: localStorage is scoped to the origin. Different subdomains can’t share data.
  • String Only: Non-serializable values (functions, symbols) will be lost during JSON.stringify.