Persisting state and plugins
Write a Pinia plugin that saves opted-in stores to localStorage and restores them safely, and know what should never be persisted.
What you'll learn
When you finish, you can do these.- 1Write a Pinia plugin and register it with pinia.use()
- 2Save opted-in stores and restore them after a reload
- 3Choose which state to save and which to leave out
- 4Spot and fix bad saved JSON and a date that turns to text
Picture a small notes page built with Pinia, Vue's official library for shared state. You pick a dark theme, make the text bigger and start a note. Those values live in stores, boxes of state that any component can read, and a store lives in memory: reload the page and all three start over.
A Pinia plugin, a function Pinia runs for every store, can save them in the browser. The Code tab holds that page and persist.ts, a plugin that already saves the theme and text size; by the end the note survives a reload too. You need two store methods from the lesson Stores working together: $patch(), which writes several values into the state at once, and $subscribe(), which runs your function after every change.
What a plugin is
A plugin is a function you register with pinia.use(). Pinia calls it once for each store, the first time that store is used.
Pinia passes it a context object, of type PiniaPluginContext. Its store is the store itself, and its options hold what the store passed to defineStore(), the function that defines a store.
In the Code tab, main.ts makes the Pinia instance with createPinia() and registers persistPlugin from persist.ts on the next line:
const pinia = createPinia()
pinia.use(persistPlugin) // before any store is first used
createApp(App).use(pinia).mount('#app')
Order matters. Pinia applies the plugins it knows about at the moment a store is created, so a store first used before pinia.use() never gets the plugin.
A plugin runs inside the store's effect scope, the store's own container for its watchers. So a $subscribe() made in a plugin lasts as long as the store, while one made in a component stops when that component leaves the page, unless it passes { detached: true }.
A persist plugin in steps
persistPlugin restores a store's saved state when the store is created, then saves the state after every change. In the diagram, useStore() stands for the function a component calls to get a store, such as usePrefsStore() in App.vue.
- first useStore()
- pluginrestore with $patch
- $subscribe
- localStorage.setItem
First it skips the stores that did not ask. A store opts in with its own option, persist: true. The prefs store, which holds the theme and text size, is a setup store, written as one function, so it passes options as the third argument of defineStore().
export const usePrefsStore = defineStore('prefs', () => {
const theme = ref<'light' | 'dark'>('light')
const fontSize = ref(16)
return { theme, fontSize }
}, { persist: true }) // options for plugins go in the third argument
The draft store, which keeps the note, passes no options yet, so the plugin skips it.
Then the plugin restores. localStorage, the browser's storage, keeps text under a key and survives a reload. Each store's key is pinia- plus its id, the first argument of defineStore(), so the prefs store saves under pinia-prefs.
The saved text is JSON, a text format for data: JSON.parse turns it back into an object, and $patch() writes its values over the starting ones.
export function persistPlugin({ store, options }: PiniaPluginContext) {
if (!options.persist) return
const key = `pinia-${store.$id}`
const saved = localStorage.getItem(key)
if (saved) {
try {
store.$patch(JSON.parse(saved))
} catch {
localStorage.removeItem(key) // not valid JSON: start from the defaults
}
}
if (!options.persist) return skips the stores that did not ask. The try/catch throws away saved text that is not valid JSON; What goes wrong shows why.
Last, the plugin subscribes. After every change, JSON.stringify turns the whole state into text and setItem() saves it.
store.$subscribe((_mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}
Toggle the theme in the preview and make the text bigger. Then type a space at the end of main.ts, which restarts the preview: the page comes back dark and large.
The declare module 'pinia' block at the end of persist.ts tells TypeScript about the new persist option.
What to save, and where
Not everything in a store should outlive a reload. Save what the person chose: a theme, a language, a text size, a draft. Do not save a loading flag, a true-or-false value that says data is on its way: saved as true, it would make the page load forever after a reload.
Never save secrets such as login tokens in localStorage, where any script on the page can read them; the Security track explains where they go instead.
localStorage exists only in the browser. During server rendering, when a server builds the HTML first, as in Nuxt, this plugin would throw ReferenceError: localStorage is not defined. Check for a browser with typeof window !== 'undefined' first, or save to cookies.
In a project, the ready-made pinia-plugin-persistedstate does all of this with more options, such as cookies for Nuxt.
What goes wrong
Two mistakes come from what the plugin saves. Saved data outlives your code, and you do not control it: a value cut off halfway, or edited by hand in the browser's tools, makes JSON.parse throw. Here is the restore step without its try/catch:
const saved = localStorage.getItem(key)
if (saved) store.$patch(JSON.parse(saved))
The plugin runs inside the first usePrefsStore(), so the error stops the whole page. The preview stays blank and shows Unexpected end of JSON input, on every load, because the bad value stays in storage. Catch the error and remove the bad value:
const saved = localStorage.getItem(key)
if (saved) {
try {
store.$patch(JSON.parse(saved))
} catch {
localStorage.removeItem(key)
}
}
The store starts from its defaults, light at 16px, and the next change saves a clean copy.
The second mistake saves a value JSON cannot hold. Say the draft store opts in and keeps the time the note was started as a date, in startedAt:
const startedAt = ref(new Date())
App.vue shows it with draft.startedAt.toLocaleTimeString(), and the first visit works. Type in the note, reload, and the page goes blank with draft.startedAt.toLocaleTimeString is not a function.
JSON has no dates: JSON.stringify turns a Date into a string such as "2026-10-11T09:30:00.000Z", and JSON.parse leaves it a string, which has no date methods. Store a number instead:
const startedAt = ref(Date.now()) // a number survives JSON
Turn it into a date where you show it, with new Date(draft.startedAt).toLocaleTimeString().
What to remember
- A plugin is a function given to
pinia.use(). Pinia calls it once per store, when the store is first used, with the store and its options. - Register plugins right after
createPinia(). A store created beforepinia.use()never gets the plugin. - A persist plugin restores with
$patch()and saves in$subscribe(). A store opts in through its options, the third argument for a setup store. - Persist choices and drafts, never loading flags or tokens, and guard
localStoragewhen the app renders on a server. - Wrap
JSON.parsein try/catch, and save only what JSON can hold: strings, numbers, booleans, arrays and plain objects.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Opt a second store in. In
stores/draft.ts, add{ persist: true }as the third argument ofdefineStore(). Type a note in the box, then restart the preview with a space at the end ofmain.ts: the note is still there. - Save a date. In the draft store, add
const startedAt = ref(new Date())and return it. InApp.vue, addimport { computed } from 'vue'and uncomment the two Try it 2 lines: the page shows the time. Type a letter in the note so the store saves, then restart the preview. It goes blank withdraft.startedAt.toLocaleTimeString is not a function. Fix it withref(Date.now())in the store andnew Date(draft.startedAt).toLocaleTimeString()inApp.vue. - Corrupt the saved prefs. In
main.ts, uncomment thelocalStorage.setItem('pinia-prefs', '{"theme":')line. The page loads with the light theme, because the try/catch threw the bad value away. Now replace the try/catch block withif (saved) store.$patch(JSON.parse(saved)): the preview goes blank and showsUnexpected end of JSON input. Put the try/catch back and comment out the line again.