useLocalStorage Hook for React
Typed React useLocalStorage hook with SSR safety, cross-tab sync via storage events, namespaced keys, and graceful fallback in private mode.
useLocalStorage is a typed React hook that mirrors useState while persisting to localStorage. It is SSR-safe, syncs across browser tabs via the storage event, namespaces keys to avoid collisions, and falls back to in-memory state when storage is unavailable (private mode, Safari ITP, server render).
Tested on React 19, Next.js 16, TypeScript 5.6.
When to Use This
- Persisting UI preferences: theme, layout, sidebar open/closed
- Remembering form drafts across reloads
- Caching the active filter on a search page
- Cross-tab sync of state like "user just logged out" or theme changes
Don't use this when the value is sensitive (auth tokens, PII), large (localStorage is ~5MB and synchronous), or needs to survive across origins. Use IndexedDB for large blobs and HTTP-only cookies for auth tokens. Also avoid this hook for state that changes 60 times a second — each setter triggers a JSON serialise + storage write.
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(
() => (namespace ? `${namespace}_${key}` : key),
[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 {
return parser ? parser(raw) : (JSON.parse(raw) as T);
} catch (error) {
console.warn(`useLocalStorage: parse failed for "${resolvedKey}".`, error);
return initialValueRef.current;
}
},
[parser, resolvedKey],
);
const readValue = useCallback((): T => {
const storage = getStorage();
if (!storage) return initialValueRef.current;
try {
return parseStoredValue(storage.getItem(resolvedKey));
} catch (error) {
console.warn(`useLocalStorage: read failed for "${resolvedKey}".`, 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(
(next: T) => {
const storage = getStorage();
if (!storage) return;
try {
const raw = serializer ? serializer(next) : JSON.stringify(next);
storage.setItem(resolvedKey, raw);
} catch (error) {
console.warn(`useLocalStorage: write failed for "${resolvedKey}".`, error);
}
},
[getStorage, resolvedKey, serializer],
);
const setStoredValue: Dispatch<SetStateAction<T>> = useCallback(
(next) => {
setValue((current) => {
const computed =
typeof next === 'function' ? (next as (prev: T) => T)(current) : next;
persistValue(computed);
return computed;
});
},
[persistValue],
);
const clearValue = useCallback(() => {
const storage = getStorage();
if (storage) {
try {
storage.removeItem(resolvedKey);
} catch (error) {
console.warn(`useLocalStorage: remove failed for "${resolvedKey}".`, error);
}
}
setValue(initialValueRef.current);
}, [getStorage, resolvedKey]);
const getRaw = useCallback(() => {
const storage = getStorage();
if (!storage) return null;
try {
return storage.getItem(resolvedKey);
} catch {
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 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;
}
}The returned tuple is [value, setValue, clear, getRaw]. clear resets to the initial value (and removes from storage), getRaw exposes the underlying string for debugging. The detectStorage probe writes-then-removes a sentinel so Safari private mode (where setItem throws) falls back cleanly.
Usage
'use client';
import { useLocalStorage } from '@/hooks/useLocalStorage';
export function ThemeSwitcher() {
const [theme, setTheme, clearTheme] = useLocalStorage<'light' | 'dark'>(
'theme',
() =>
typeof window !== 'undefined' &&
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>
);
}The namespace prefix (app_theme) keeps your keys isolated from any other library writing to localStorage. Pass the same namespace from every hook call in your app.
Pitfalls
'use client'is required in Next.js App Router. Server components cannot accesslocalStorage. If you import this hook into a server component you will get a build error.- Initial render uses
initialValue, not the stored value. The first paint shows the default, then theuseEffectswaps in the stored value. To avoid flash-of-default, render the dependent UI behind amountedflag. - Storage writes are synchronous. Setting state 60 times a second triggers 60 JSON serialise + write cycles, which can jank scroll. Debounce the setter for high-frequency updates.
- The
storageevent does not fire in the same tab. Two hooks in the same tab listening for the same key will not sync automatically. They will sync only if a different tab triggers the change. - JSON serialiser drops
undefined,Date,Map,Set, and functions. Use a customserializer/parserif you need richer types, or pre-stringify dates as ISO strings.
Related Snippets & Reading
- useDebounce Hook for React — pairs with
useLocalStorageto throttle persisted writes - useOnlineStatus Hook for React — another browser-API hook with SSR-safe fallback
- Replacing useEffect Data Fetching with Server Actions — when client state is the wrong tool
Frequently Asked Questions
Is useLocalStorage SSR-safe in Next.js?
Yes. The hook returns the initial value synchronously on the server (no `window` access), then syncs from `localStorage` after mount via `useEffect`. There is no hydration mismatch as long as the initial value matches what the server rendered. If the persisted value differs from the initial, expect a single client-side update on mount.
How does the hook stay in sync across browser tabs?
It listens for the native `storage` event, which fires in every other tab when one tab writes to `localStorage`. The handler filters by storage area and exact key, then updates local state. This works across tabs from the same origin but does not fire in the tab that performed the write, which is intentional browser behaviour.