localStorage in React, done properly
The short answer
To use localStorage in React safely you need four things: a lazy useState initialiser so you read storage only once, a typeof window guard so server rendering does not crash, a try/catch around every read and write because storage can be disabled or full, and a storage event listener so other tabs stay in sync. useLocalStorage from @danixsoft/hooks handles all four.
Persisting a preference sounds like a two-line job. The two-line version has four bugs in it, and every one of them shows up in production rather than on your machine.
Bug 1: reading storage on every render
useState(expensiveCall()) evaluates its argument on every single render, then throws the result away on all but the first. localStorage.getItem is a synchronous, blocking, main-thread call — doing it 60 times a second during an animation is measurable.
Bug 2: the server has no localStorage
Under Next.js this crashes the build outright. The initialiser must return the fallback when there is no window, which also keeps the server HTML and the first client render in agreement.
Bug 3: storage throws more often than you think
Every access can raise. Safari in private browsing has historically thrown on write; browsers with cookies blocked can throw on read; the quota is finite and exceeding it throws; and any hand-edited or half-written value makes JSON.parse throw.
Key takeaway
Persistence is an enhancement, never a requirement. If storage fails, the component must keep working with in-memory state — a failed write should cost the user their preference, not their session.
Bug 4: two tabs, two truths
Open your app in two tabs, change the theme in one, and the other keeps the old value until it reloads. The browser fires a storage event for exactly this — but only in the other tabs, never the one that made the change.
For components inside the same tab to stay in sync, the setter also dispatches a synthetic StorageEvent — otherwise two components using the same key would drift apart. useLocalStorage does this for you.
The finished hook
Which storage should you actually use?
Never store credentials
localStorage is readable by any JavaScript running on your origin, which includes anything injected through an XSS vulnerability. Access tokens belong in an httpOnly cookie the browser will not hand to scripts at all.What not to persist
- Auth tokens and API keys — use httpOnly cookies.
- Anything personal you have not disclosed — persisted identifiers can bring the storage under GDPR and similar regimes.
- Large data sets — the quota is small and the API is synchronous; use IndexedDB past a megabyte or so.
- Server-derived state — a cached API response goes stale silently and is far more confusing than a refetch.
Why does my localStorage value flash the default on first paint?
Because the server rendered the default and the browser only reads storage after hydration. That is correct and necessary to avoid a hydration mismatch. If the flash is unacceptable for something as visible as a theme, add a blocking inline script in the document head to apply the stored value before the first paint.
How much can I store in localStorage?
Roughly 5 MB per origin in most browsers, though the exact quota varies and is shared with other storage. Exceeding it throws a QuotaExceededError, which is why every write should sit inside a try/catch.
Does useLocalStorage work in React Native?
No. React Native has no localStorage; use AsyncStorage or MMKV instead. The hook works in any browser environment, including React Native Web.
How do I clear a persisted value?
Set it back to the initial value, or call window.localStorage.removeItem(key) directly and update state. Removing the key means the next mount falls back to the initial value you passed.
Keep reading
SSR-safe hooks in Next.js
Why "window is not defined" and hydration mismatches happen, and the four patterns that fix them for good in Next.js, Remix and any server-rendered React app.
Writing custom hooks that survive production
Naming, return shapes, dependency arrays, cleanup, testing and TypeScript patterns for custom React hooks that other people have to maintain.