useLocalStorage — useState backed by localStorage with cross-tab sync
Recipe
import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
// Lazy initializer reads from storage only once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const item = localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
// Persist to localStorage whenever value or key changes
useEffect(() => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
// Storage quota exceeded or unavailable
}
}, [key, storedValue]);
// Sync across tabs via the storage event
useEffect(() => {
if (typeof window === "undefined") return;
const handleStorage = (e: StorageEvent) => {
if (e.key !== key) return;
if (e.newValue === null) {
setStoredValue(initialValue);
} else {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch {
// Ignore malformed JSON
}
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [key, initialValue]);
// Setter that matches useState signature (value or updater fn)
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const nextValue =
value instanceof Function ? value(prev) : value;
return nextValue;
});
},
[]
);
// Remove the key from storage and reset to initial
const remove = useCallback(() => {
if (typeof window !== "undefined") {
localStorage.removeItem(key);
}
setStoredValue(initialValue);
}, [key, initialValue]);
return [storedValue, setValue, remove];
}When to reach for this: You want component state to survive page refreshes, and optionally stay in sync across browser tabs, without pulling in an external library.
Working Example
"use client";
function ThemeToggle() {
const [theme, setTheme, removeTheme] = useLocalStorage<"light" | "dark">(
"app-theme",
"light"
);
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
Toggle Theme
</button>
<button onClick={removeTheme}>Reset to Default</button>
</div>
);
}
function FormDraft() {
const [draft, setDraft] = useLocalStorage("form-draft", {
name: "",
email: "",
});
return (
<form>
<input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="Name"
/>
<input
value={draft.email}
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="Email"
/>
<p>Draft auto-saved to localStorage</p>
</form>
);
}What this demonstrates:
- Type-safe storage with union type
"light" | "dark" - Updater function syntax
(prev) => nextjust likeuseState - Object storage with JSON serialization
removefunction to clear the key and reset state- Opening two tabs shows the theme syncing via the
storageevent
Deep Dive
How It Works
- Lazy initializer: The
useStatecallback reads fromlocalStorageonly on first render, avoiding redundant reads on every re-render. - SSR guard: Every
windowaccess is gated behindtypeof window === "undefined"checks, so the hook returnsinitialValueduring server-side rendering with no hydration mismatch. - JSON round-trip: Values are serialized with
JSON.stringifyand deserialized withJSON.parse. This handles primitives, arrays, and plain objects. - Cross-tab sync: The
storageevent fires in other tabs when the same key changes. The listener updates local state to match. - Functional updates: The setter accepts either a direct value or a function
(prev) => next, mirroring theuseStateAPI.
Parameters & Return Values
| Parameter | Type | Default | Description |
|---|---|---|---|
key | string | — | The localStorage key |
initialValue | T | — | Fallback when key is missing or on SSR |
| Return Index | Type | Description |
|---|---|---|
[0] | T | Current value |
[1] | (value: T or ((prev: T) => T)) => void | Setter (value or updater function) |
[2] | () => void | Remove key and reset to initial |
Variations
With expiration: Add a TTL by storing { value, expiresAt } and checking on read:
const item = JSON.parse(raw);
if (item.expiresAt && Date.now() > item.expiresAt) {
localStorage.removeItem(key);
return initialValue;
}
return item.value;With custom serializer: Accept serialize and deserialize options for non-JSON data (e.g., superjson for Date objects, Map, Set).
sessionStorage variant: Swap localStorage for sessionStorage — the API is identical, but data clears when the tab closes.
TypeScript Notes
- The generic
Tflows frominitialValueto the stored state and setter, giving full type inference. - Union types like
"light" | "dark"narrow the setter input automatically. - For complex types, pass the generic explicitly:
useLocalStorage<User>("user", defaultUser).
Gotchas
- Non-serializable values — Functions, Symbols,
undefined, and circular objects cannot surviveJSON.stringify. Fix: Only store plain serializable data. Usesuperjsonfor Date, Map, Set. - Storage quota exceeded — Browsers limit localStorage to around 5 MB. Fix: The try/catch in the write effect silently handles this; consider warning the user.
- storage event fires only in other tabs — The same tab that wrote the value does not receive a
storageevent. Fix: This hook handles same-tab updates viasetState; cross-tab sync is handled by the event listener. - Key collisions — Multiple apps on the same origin share localStorage. Fix: Prefix keys with an app namespace like
myapp:theme. - Hydration mismatch — If the server renders with
initialValuebut the client reads a different value from storage, a mismatch occurs. Fix: The lazy initializer runs only on the client. For SSR frameworks, the initial render usesinitialValue, then updates after hydration.
Alternatives
| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useLocalStorage | Popular, similar API |
@uidotdev/usehooks | useLocalStorage | Minimal, well-tested |
ahooks | useLocalStorageState | Supports custom serializer |
jotai | atomWithStorage | Atom-based persistence |
zustand | persist middleware | Store-level persistence |
FAQs
How does cross-tab syncing work in this hook?
- The
storageevent fires in other tabs when the same key is written to localStorage. - The hook listens for this event and updates local state when it detects a change to its key.
- The same tab that wrote the value does not receive the event; it updates via
setStatedirectly.
Why does the hook use a lazy initializer in useState instead of reading localStorage in the function body?
The lazy initializer (callback form of useState) runs only on the first render. Reading localStorage on every render would be wasteful since the stored value only needs to be read once at mount time.
What types of values cannot be stored with this hook?
- Functions and Symbols are silently dropped by
JSON.stringify. undefinedis also dropped (becomesnull).- Circular object references throw an error.
Date,Map, andSetlose their types (become strings/arrays). Usesuperjsonfor these.
What happens when localStorage is full?
Browsers limit localStorage to about 5 MB per origin. When the limit is reached, setItem throws. The hook wraps the write in a try/catch so the app does not crash, but the data is silently not persisted.
How do I avoid key collisions with other apps on the same domain?
Prefix your keys with an app-specific namespace:
const [theme, setTheme] = useLocalStorage("myapp:theme", "light");Gotcha: I see a hydration mismatch flash when the server-rendered value differs from localStorage. Why?
The server renders with initialValue, but the client reads a different value from storage on mount. The lazy initializer runs only on the client, causing a brief mismatch. This is expected; for critical UI, consider delaying the render until hydration completes.
Gotcha: My stored object loses its Date fields after a page refresh. How do I fix this?
JSON.parse converts Date strings back to plain strings, not Date objects. Use a custom serializer like superjson that preserves types, or manually re-hydrate dates after reading.
How does the setter support both direct values and updater functions like useState?
The setValue callback checks value instanceof Function. If true, it calls the function with the previous state. Otherwise, it uses the value directly. This mirrors the useState API.
How does TypeScript infer the stored type automatically?
The generic T is inferred from initialValue. For example, useLocalStorage("theme", "light") infers T as string. For union types, pass the generic explicitly:
useLocalStorage<"light" | "dark">("theme", "light");Can I use this hook with sessionStorage instead?
Yes. The sessionStorage API is identical to localStorage. Swap every localStorage call for sessionStorage. The only difference is that data clears when the browser tab closes.
Related
- useToggle — combine with localStorage for persistent toggles
- useMediaQuery — detect system theme preference
- useState — the underlying primitive