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 Managementreacthookslocalstoragestate
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
localStoragewith safe JSON parsing and configurable defaults. - Persists every state update, supporting both direct and functional setters.
- Listens for
storageevents 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.