Persistent state in Vue 3, without reaching for Pinia
Theme, filters, drafts, carts: most 'state management' problems are really persistence problems. How to keep Vue state across reloads and tabs — the hand-rolled way, and the one-line way.
Every Vue app eventually grows a handful of values that must outlive a page reload: the chosen theme, a search filter, a half-written form, a cart. And every team eventually has the same conversation about them: "should we add Pinia for this?"
Usually, no. A store solves shared state; these are persistent values — and gluing persistence onto a store still leaves you writing the same plumbing. Let's look at what that plumbing actually involves, because the details are where the bugs live.
The hand-rolled version (and its traps)
The naive approach fits in six lines:
import { ref, watch } from 'vue';
const theme = ref(JSON.parse(localStorage.getItem('theme') ?? '"light"'));
watch(theme, (value) => localStorage.setItem('theme', JSON.stringify(value)));
It works — on your machine, today. In production it has at least five holes:
- SSR crashes. On Nuxt or any server render,
localStoragedoesn't exist. First hydration error of the sprint. - Corrupt data throws. One malformed value in storage (an old release, an extension, a curious user) and
JSON.parsetakes your component down with it. - Tabs disagree. Change the theme in one tab and the other five keep the old one until reload. The
storageevent fixes it — if you remember it, and remember to remove the listener — otherwise you've built a memory leak. - Stale values live forever. A cached token, a draft from last month: without an expiry, storage becomes a junk drawer your app trusts blindly.
- Write storms. Persisting a text input on every keystroke hammers a synchronous API on the main thread.
None of these is hard. All of them together, done in every component that needs them, is exactly the kind of code that grows five slightly different implementations in one codebase — I audit front-ends for a living, and I can promise you it does.
The one-line version
This is the itch behind vue-smart-state, a small open-source composable I maintain (~2 kB, zero dependencies — full disclosure, this is my package):
import { useState } from 'vue-smart-state';
const [theme, setTheme] = useState<'light' | 'dark'>('light', {
persist: true,
storageKey: 'theme',
syncTabs: true,
});
One call gives you the whole checklist: restore on load, persist on change, cross-tab sync with proper listener cleanup, SSR safety, and error handling that degrades instead of throwing. The API deliberately mirrors React's useState — a tuple plus a functional updater (setTheme(t => …)) — because half the developers joining a Vue team carry that muscle memory with them.
The production details are options away:
// A session that expires on its own
const [token, setToken] = useState('', {
persist: true,
storageKey: 'token',
ttl: 15 * 60 * 1000,
});
// A draft that doesn't hammer storage on every keystroke
const [draft, setDraft] = useState('', {
persist: true,
storageKey: 'draft',
writeDebounce: 300,
});
And for Nuxt there's a one-liner module, nuxt-smart-state, that auto-imports the composable as useSmartState — aliased on purpose, since Nuxt's own useState solves a different problem (per-request server state, not browser persistence).
When you do want a store
Honesty corner: if many distant components mutate the same complex state, if you need devtools time-travel, or if your team benefits from one blessed pattern for app-wide data — use Pinia — it's excellent. The point isn't "stores are bad"; it's that persistence is not a reason to adopt one. Pick the store for sharing, pick a persistence composable for persisting, and let each do its job.
The wider lesson is one I keep repeating in architecture work: most "we need a state management solution" conversations dissolve once you name the actual requirement. Sharing, persisting, syncing and caching are four different problems wearing the same T-shirt.
I'm an independent software architect focused on front-end work — and I occasionally package the boring parts well, like the composable above. If your Vue codebase has grown five ways of doing the same thing, let's talk: a 30-minute intro call is free.