Mikko Haapanen

React localStorage backed state: easy to start, hard to get right

May 15, 2026 • ☕️☕️ 7 min read

When localStorage makes sense over back-end persistence

In general, I think localStorage is suitable for some user preferences, last-used values, and UI settings. Anything that’s convenient to remember but not actual hard user data.

My example app, which I will reference throughout this post, is a specialized ID photo editing tool with purchase-based access (activation codes without registration or user accounts). The app has a back-end for key validation, but no user identity. That is, no login, no user profiles.

Concrete example settings in the ID photo editor that can be persisted in localStorage include mm/inch unit choice, last-used sheet size (A6, A4…), photos-per-sheet setting, and remembered tab selection in the save modal (quick save vs. advanced). In the example app, adding per-user persistence server-side is definitely possible, but starting to save the first user preference has a high cost: designing and creating db structures, API endpoints, auth context, etc.

When the infrastructure already exists, adding a new saved field is cheap. When it doesn’t, it’s a non-trivial commitment for what is essentially a convenience feature. The difficulty of implementation, of course, isn’t the dominant factor in the decision, but it is a real aspect to consider when the features can be implemented either way, localStorage or back-end.

Summarized criteria where localStorage persistence makes sense:

  • Non-critical UX convenience data. Resetting data at arbitrary times is acceptable.
  • No cross-device sync needed.
  • No existing user persistence infrastructure to leverage.

On the contrary, when back-end/database makes more sense: cross-device access is needed, the data has hard value, the data is needed server-side, or the infrastructure is already in place (low marginal cost of adding a new persisted field).

Simple, just localStorage.setItem(). And then the problems start.

My first implementation: component reads on mount, writes on change. This worked fine for a single isolated component, and for a happy path on my dev machine. However, problems surface once requirements accumulate and you hit production.

Problem 1: Component sync

Same key, two components. Changing the value in one doesn’t automatically update the other. E.g., in my example app, mm/inch preference is shown and used in multiple places. Side note: the mm/inch toggle in multiple places makes sense, since in an ID photo editor, users might want to change the settings based on the photo standard they’re working on. It’s not necessarily just a one-and-done setting.

Simple fix: lift state to a parent, or use React Context. This sounds easy, and the simple happy case really is trivial. The reality (that I learned the hard way through multiple projects and implementations) is that this still requires work, both initially and in maintaining the solution, especially when you encounter the other problems below, one by one, over time.

Problem 2: Cross-tab sync

The same app is open in two tabs. LocalStorage changes in one tab aren’t automatically reflected in the other. A plausible scenario for some web apps, even if not a primary use case.

The browser does fire StorageEvent for cross-tab changes, but wiring it into React state correctly takes work. Note: the event only fires for changes from other tabs, not for changes in the current tab, which can be a gotcha if you expect it to trigger on every change.

Within a tab: hook uses an internal store; across tabs: listens to StorageEvent.

Problem 3: External changes not detected

Ok, so StorageEvent isn’t fired for changes from the current tab. No problem, right? We already have the correct React state for the current tab. Well, not always. The source of truth should be localStorage, not React state.

When debugging, you might do as I sometimes do: change a value directly in DevTools → the app doesn’t react to the change because it doesn’t know about it. Or, you might have a “Reset to defaults” button that clears the localStorage keys. Depending on how you implement it, the app might not react to that either.

Another potential scenario: a browser extension or another script on the page modifies localStorage. The app won’t react to those changes either.

Solution: polling, that is, compare the stored value to the known React state on an interval and update the React state if the value is different.

I ended up with a configurable interval and a disableable polling mechanism. For me, polling is useful during development, but it makes no sense to keep it on in production. I can see how it could be useful in some production scenarios and in other apps.

Problem 4: Serialization and schema migration

I thought I would only store strings. Then I thought it would only be strings, numbers, or other primitives. Then, after realizing I’ll be storing more complex data, I thought, I’ll just use JSON for everything. However, JSON has gotchas with primitives:

  • JSON.stringify("hello")"hello" (literal quotes in storage). This is confusing in DevTools when debugging, setting the state manually, reading without JSON parsing or when other tools read the key.
  • NaN, Infinity, -Infinity all become null after a JSON round-trip.
  • A string that looks like an object, such as {"name":"Alice"}, parses as an actual object.

Default behavior I have found best: store primitives as plain strings and objects/arrays as JSON-serialized strings.

Schema migration: Concrete case: persisting the last filled values on a form where multiple related fields are saved as a group. Field-by-field persistence gets verbose fast. Grouping the values into a single persisted object under a single key also lets you save only when the whole form is valid, avoiding invalid partial states.

Also, type changes happen even on simple values: a numeric ID becomes a string, a boolean becomes an enum, or a field gets renamed.

Users already have the old format in their browsers in production. A broken deserialization corrupts the state, crashes the app, or, at minimum, loses the persisted value, resulting in a bad user experience.

A good solution allows you to handle potential migrations gracefully. And, even if you don’t have a migration strategy in place, the component should at least avoid breaking the app when it encounters an unexpected format.

Problem 5: Reliability edge cases

SSR/Next.js: localStorage doesn’t exist on the server, and accessing it throws. My hook solution uses useSyncExternalStore (with a shim for React 16.8+) internally to render the default value on the server and sync on the client.

Storage errors: For example, QuotaExceededError. Meaning: storage full or storage blocked entirely. If you have Sentry or other error tracking in place, you’ve probably seen these scary-looking errors. They’re not critical; your app likely didn’t crash in production, but they are still real and happen for a reason. My hook catches these and falls back to in-memory storage for that key only. State with inter-component synchronization works for the session, and the app keeps running.

The point where it became an npm package

The same logic (component sync, cross-tab, configurable polling, serialization, SSR support, error handling) was getting copy-pasted and customized a bit from project to project. Each copy diverged. Each project seemed to have its own unique edge case and requirement that the others didn’t have and handle, which made it harder to maintain and keep track of all the variations. Complexity had grown enough that “just inline it” was no longer a good solution.

In hindsight, extracting the logic into a package forced rigor: stable, generic yet simple API, proper TypeScript types, edge cases thought through and handled, unit tests, and some documentation and usage examples.

I learned that even if no one else uses the package, it’s still worth it for me to have a single, well-maintained implementation I can rely on and easily update across projects. Creating it forced me to think through the API design and edge cases more thoroughly, which ultimately led to a better solution than the various ad-hoc implementations I had before, which had bugs.

The custom React hook

The hook’s API mirrors useState.

const [count, setCount] = useStoragePersistedState("count", 0)

Full docs and examples are on GitHub: use-storage-persisted-state


Written by Mikko Haapanen who lives and works in Helsinki, Finland building useful things.