Snippets/react/useLocalStorage Hook
intermediatereact

useLocalStorage Hook

Reactive wrapper over browser localStorage that keeps React state persisted, cross-tab aware, and SSR safe.

Published October 17, 2025
Updated October 17, 2025
State Management
reacthookslocalstoragestate

useLocalStorage Hook

A resilient React hook that mirrors useState while persisting values to localStorage. It gracefully handles SSR, cross-tab synchronisation, and JSON serialisation so your UI stays in sync with browser storage without boilerplate.

Highlights

  • Initializes from localStorage with safe JSON parsing and configurable defaults.
  • Persists every state update, supporting both direct and functional setters.
  • Listens for storage events to stay in sync across tabs and browser windows.
  • Namespaces keys to avoid collisions and offers optional raw access for diagnostics.
  • Survives SSR or private-mode scenarios by falling back to in-memory state when storage is unavailable.

Code

import {
  Dispatch,
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
 
type InitialValue<T> = T | (() => T);
type Serializer<T> = (value: T) => string;
type Parser<T> = (value: string) => T;
 
interface UseLocalStorageOptions<T> {
  namespace?: string;
  serializer?: Serializer<T>;
  parser?: Parser<T>;
  listenToStorageEvents?: boolean;
  storage?: Storage;
}
 
type UseLocalStorageReturn<T> = [
  T,
  Dispatch<SetStateAction<T>>,
  () => void,
  () => string | null
];
 
const STORAGE_TEST_KEY = '__useLocalStorage_test__';
 
export function useLocalStorage<T>(
  key: string,
  initialValue: InitialValue<T>,
  options: UseLocalStorageOptions<T> = {}
): UseLocalStorageReturn<T> {
  if (!key?.trim()) {
    throw new Error('useLocalStorage requires a non-empty key.');
  }
 
  const {
    namespace,
    serializer,
    parser,
    listenToStorageEvents = true,
    storage: storageOverride,
  } = options;
 
  const resolvedKey = useMemo(() => createNamespacedKey(key, namespace), [key, namespace]);
  const initialValueRef = useRef(resolveInitial(initialValue));
  const storageRef = useRef<Storage | null | undefined>(undefined);
 
  const getStorage = useCallback(() => {
    if (storageRef.current !== undefined) {
      return storageRef.current;
    }
    const resolved = storageOverride ?? detectStorage();
    storageRef.current = resolved;
    return resolved;
  }, [storageOverride]);
 
  const parseStoredValue = useCallback(
    (raw: string | null): T => {
      if (raw === null) {
        return initialValueRef.current;
      }
      try {
        if (parser) {
          return parser(raw);
        }
        return JSON.parse(raw) as T;
      } catch (error) {
        console.warn(`useLocalStorage: Failed to parse value for "${resolvedKey}".`, error);
        return initialValueRef.current;
      }
    },
    [parser, resolvedKey]
  );
 
  const readValue = useCallback((): T => {
    const storage = getStorage();
    if (!storage) {
      return initialValueRef.current;
    }
    try {
      const raw = storage.getItem(resolvedKey);
      return parseStoredValue(raw);
    } catch (error) {
      console.warn(`useLocalStorage: Unable to read "${resolvedKey}" from storage.`, error);
      return initialValueRef.current;
    }
  }, [getStorage, parseStoredValue, resolvedKey]);
 
  const [value, setValue] = useState<T>(readValue);
 
  useEffect(() => {
    initialValueRef.current = resolveInitial(initialValue);
  }, [initialValue]);
 
  useEffect(() => {
    setValue(readValue());
  }, [readValue, resolvedKey]);
 
  const persistValue = useCallback(
    (nextValue: T) => {
      const storage = getStorage();
      if (!storage) {
        return;
      }
      try {
        const raw =
          serializer !== undefined ? serializer(nextValue) : JSON.stringify(nextValue);
        storage.setItem(resolvedKey, raw);
      } catch (error) {
        console.warn(`useLocalStorage: Unable to persist "${resolvedKey}".`, error);
      }
    },
    [getStorage, resolvedKey, serializer]
  );
 
  const setStoredValue: Dispatch<SetStateAction<T>> = useCallback(
    (next) => {
      setValue((current) => {
        const nextValue = typeof next === 'function' ? (next as (prev: T) => T)(current) : next;
        persistValue(nextValue);
        return nextValue;
      });
    },
    [persistValue]
  );
 
  const clearValue = useCallback(() => {
    const storage = getStorage();
    if (storage) {
      try {
        storage.removeItem(resolvedKey);
      } catch (error) {
        console.warn(`useLocalStorage: Unable to remove "${resolvedKey}".`, error);
      }
    }
    setValue(initialValueRef.current);
  }, [getStorage, resolvedKey]);
 
  const getRaw = useCallback(() => {
    const storage = getStorage();
    if (!storage) {
      return null;
    }
    try {
      return storage.getItem(resolvedKey);
    } catch (error) {
      console.warn(`useLocalStorage: Unable to access raw value for "${resolvedKey}".`, error);
      return null;
    }
  }, [getStorage, resolvedKey]);
 
  useEffect(() => {
    if (!listenToStorageEvents || typeof window === 'undefined') {
      return;
    }
    const storage = getStorage();
    if (!storage) {
      return;
    }
 
    const handleStorage = (event: StorageEvent) => {
      if (event.storageArea !== storage || event.key !== resolvedKey) {
        return;
      }
      setValue(parseStoredValue(event.newValue));
    };
 
    window.addEventListener('storage', handleStorage);
    return () => window.removeEventListener('storage', handleStorage);
  }, [getStorage, listenToStorageEvents, parseStoredValue, resolvedKey]);
 
  return [value, setStoredValue, clearValue, getRaw];
}
 
function resolveInitial<T>(initial: InitialValue<T>): T {
  return typeof initial === 'function' ? (initial as () => T)() : initial;
}
 
function createNamespacedKey(key: string, namespace?: string) {
  if (!namespace) {
    return key;
  }
  return `${namespace}_${key}`;
}
 
function detectStorage(): Storage | null {
  if (typeof window === 'undefined') {
    return null;
  }
  try {
    const storage = window.localStorage;
    storage.setItem(STORAGE_TEST_KEY, '1');
    storage.removeItem(STORAGE_TEST_KEY);
    return storage;
  } catch {
    return null;
  }
}

Usage

import { useLocalStorage } from '@/hooks/useLocalStorage';
 
export function ThemeSwitcher() {
  const [theme, setTheme, clearTheme] = useLocalStorage<'light' | 'dark'>(
    'theme',
    () => (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),
    { namespace: 'app' }
  );
 
  return (
    <div>
      <p>Theme: {theme}</p>
      <button onClick={() => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))}>
        Toggle
      </button>
      <button onClick={clearTheme}>Reset</button>
    </div>
  );
}
 
const [, , , getRawTheme] = useLocalStorage('theme', 'light');
console.log('Raw value:', getRawTheme());

💡 When using this hook during SSR (Next.js, Remix, etc.), ensure rendering happens within useEffect-driven components or guards so that browser-only APIs are accessed after hydration.