Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
"light" | "dark"(prev) => next just like useStateremove function to clear the key and reset statestorage eventuseState callback reads from localStorage only on first render, avoiding redundant reads on every re-render.window access is gated behind typeof window === "undefined" checks, so the hook returns initialValue during server-side rendering with no hydration mismatch.JSON.stringify and deserialized with JSON.parse. This handles primitives, arrays, and plain objects.storage event fires in other tabs when the same key changes. The listener updates local state to match.(prev) => next, mirroring the useState API.| 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 |
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.
T flows from initialValue to the stored state and setter, giving full type inference."light" | "dark" narrow the setter input automatically.useLocalStorage<User>("user", defaultUser).undefined, and circular objects cannot survive JSON.stringify. Fix: Only store plain serializable data. Use superjson for Date, Map, Set.storage event. Fix: This hook handles same-tab updates via setState; cross-tab sync is handled by the event listener.myapp:theme.initialValue but 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 uses initialValue, then updates after hydration.| 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 |
storage event fires in other tabs when the same key is written to localStorage.setState directly.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.
JSON.stringify.undefined is also dropped (becomes null).Date, Map, and Set lose their types (become strings/arrays). Use superjson for these.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.
Prefix your keys with an app-specific namespace:
const [theme, setTheme] = useLocalStorage("myapp:theme", "light");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.
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.
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.
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");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.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥