useLocalStorage Hook (React)
A drop-in replacement for useState that remembers its value across reloads and stays in sync between tabs.
import { useEffect, useRef, useState } from "react";
/**
* Like useState, but the value is saved in localStorage and
* restored on the next visit. Values must be JSON-serialisable.
*/
export function useLocalStorage(key, initialValue) {
const initialRef = useRef(initialValue);
const [value, setValue] = useState(() => {
try {
const stored = window.localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
// No window (server render), storage blocked, or invalid JSON
return initialValue;
}
});
// Save whenever the value changes
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage full or disabled — keep working in memory
}
}, [key, value]);
// Stay in sync when another tab changes the same key
useEffect(() => {
function onStorage(event) {
if (event.storageArea !== window.localStorage || event.key !== key) return;
try {
setValue(event.newValue !== null ? JSON.parse(event.newValue) : initialRef.current);
} catch {
// Ignore values we can't parse
}
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key]);
return [value, setValue];
}A custom hook is just a function whose name starts with use and that calls other hooks. This one wraps useState and adds two side effects: save to localStorage when the value changes, and listen for changes from other tabs.
Line by line
useState(() => …)uses a lazy initialiser: the function runs only on the first render, so we read and parse storage once, not on every render.localStorageonly stores strings, so values go throughJSON.stringifyon the way in andJSON.parseon the way out. That's why values must be JSON-friendly (no functions, Dates become strings).- Every storage call is in
try/catch: storage can be disabled, full (QuotaExceededError) or hold invalid JSON. The hook falls back to normal in-memory state instead of crashing your component. - The first
useEffectruns after render wheneverkeyorvaluechanges, so saving never slows down the render itself. - The browser fires a
storageevent in other tabs of the same site when one tab writes. The second effect listens for it and updates state, then removes the listener in its cleanup function. initialRefremembers the firstinitialValue, so passing a new array/object literal on every render doesn't re-subscribe the listener.
Know the limits
Don't store secrets or tokens here — any script on the page can read localStorage. With server rendering (e.g. Next.js), the server always renders initialValue; use the hook only in client components. Changing key at runtime doesn't reload the stored value — remount the component (give it a different key prop) instead.
Try it: create a React project in the Playground, paste the hook and the usage example, then press Run twice — the value survives because the preview emulates localStorage per project.
Usage
import { useLocalStorage } from "./useLocalStorage.js";
export default function App() {
const [name, setName] = useLocalStorage("profile-name", "");
const [fontSize, setFontSize] = useLocalStorage("reader-font-size", 16);
return (
<main style={{ fontSize }}>
<label>
Your name{" "}
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<p>Hello, {name || "stranger"}! Reload — I still remember you.</p>
<button type="button" onClick={() => setFontSize((s) => s + 2)}>A+</button>
<button type="button" onClick={() => setFontSize((s) => Math.max(12, s - 2))}>A−</button>
</main>
);
}