Skip to content
TutorialPinia

Persist Pinia state with a plugin

A reload empties the cart. Thirty lines of plugin fix that for every store that opts in, and you will understand exactly what pinia-plugin-persistedstate does when you reach for it later.

vuejs.school team7 Oct 20256 min readPinia 3.0 · VueUse 13.9
cartitemstotaladd()refsRef<items>Ref<total>

A user fills a cart, reloads the page, and the cart is empty. Pinia state lives in memory, and memory does not survive a reload. The fix is to write the state to localStorage on every change and read it back when the store is created. You could do that by hand in each store. A Pinia plugin does it once, for every store that asks for it, and keeps the stores themselves free of browser code.

Here you write that plugin yourself, with Pinia 3.0 in a Vite project, so you know exactly what pinia-plugin-persistedstate would do for you before you decide whether to install it.

What a Pinia plugin is

A plugin is a function that Pinia calls once per store, at the moment the store is first used. It receives a context object with four fields, as the plugins guide describes:

  • store: the store being created.
  • options: the options you passed to defineStore().
  • app: the Vue application.
  • pinia: the instance from createPinia().

Whatever the function returns is merged into the store, which is how plugins add properties. Ours adds none; it only needs store and options.

You register a plugin with pinia.use(), and the order matters: plugins only apply to stores created after the plugin was added and after Pinia was passed to the app. So the registration goes in main.ts, before mount:

src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { persistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(persistPlugin)

createApp(App).use(pinia).mount('#app')

Step 1: save on every change

Every store has $subscribe(), which runs a callback after the state changes. It fires once per $patch() call rather than once per property, which is what makes it better than watch() for this job. The callback receives a mutation object and the new state; the plugin only needs the state.

src/plugins/persist.ts
import type { PiniaPluginContext } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  const key = `pinia:${store.$id}`

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  }, { detached: true })
}

The key includes store.$id, the string you gave defineStore(), so a cart store and a user store never overwrite each other.

By default, a subscription made in a component's setup() is removed when that component unmounts, and { detached: true } prevents that. Pinia runs plugins in the store's own scope, not the component's, so a plugin's subscription lasts as long as the store anyway; the option only makes that explicit.

Step 2: restore when the store is created

Step 1 alone loses data. After a reload the store starts empty, and the first change saves that fresh state over the stored one. The plugin must read the saved state back when the store is created. Do it before subscribing, or the restore triggers a write of the data you just read. $patch() with an object merges it into the state, which is exactly a restore:

src/plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  const key = `pinia:${store.$id}`

  const saved = localStorage.getItem(key)
  if (saved) {
    try {
      store.$patch(JSON.parse(saved) as StateTree)
    } catch {
      localStorage.removeItem(key)
    }
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  }, { detached: true })
}

The try matters because storage is outside your control: the user can edit it in DevTools, an extension or another script on the same origin can write to it, and JSON.parse throws on anything that is not JSON. Without the try, one bad string stops the store from being created, and the bug exists only in that user's browser. Throw the bad value away and start fresh.

Step 3: let each store opt in

Persisting every store is wrong, for reasons the last section covers, so the plugin should do nothing unless a store asks. The guide shows how plugins read custom options: anything extra you pass to defineStore() arrives in context.options. For an options store it sits beside state and actions. For a setup store it is the third argument.

src/stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref<{ id: string, qty: number }[]>([])
  const count = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))

  function add(id: string) {
    const existing = items.value.find(item => item.id === id)
    if (existing) existing.qty++
    else items.value.push({ id, qty: 1 })
  }

  return { items, count, add }
}, { persist: true })

TypeScript does not know about persist yet and rejects { persist: true } as an unknown option. Declare it with a module augmentation, a declare module 'pinia' block that adds the field to Pinia's DefineStoreOptionsBase interface, as the guide's TypeScript section does. Put it in the plugin file, next to the code that reads it:

src/plugins/persist.ts
declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | { version: number }
  }
}

export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist) return
  // ...the save and restore code from above
}

Now a store without the option is untouched, and a typo like persits is a compile error instead of a silent no-op.

Step 4: keep it off the server

If the app is server-rendered, with Nuxt or with Vite SSR, the plugin also runs on the server, where localStorage does not exist and the call throws. A one-line guard at the top of the plugin fixes it:

src/plugins/persist.ts
export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist || typeof window === 'undefined') return
  // ...
}

In Nuxt there is one more step: register the plugin in a Nuxt plugin, since Nuxt creates the Pinia instance for you. Following the guide's Nuxt example, that is defineNuxtPlugin(({ $pinia }) => { $pinia.use(persistPlugin) }), in a file under app/plugins/ in a Nuxt 4 project.

The guard stops the crash, but a server-rendered app has a second problem. The server renders the store without the saved data, and when the plugin restores it on the client, the first client render can differ from the server HTML. Vue reports that as a hydration mismatch. To avoid it, move the restore out of the plugin and run it after hydration, for example from onMounted() in the root component (App.vue, or app.vue in Nuxt). For Nuxt, the package compared below has a module built for server rendering.

Step 5: version the saved data

The shape of your state will change. You rename items to lines, or qty becomes an object. Every visitor who has old data in storage then patches an old shape into a new store, and the result is a cart that renders undefined. Store a version number next to the data and refuse anything saved under another version. This is the complete plugin file:

src/plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?: boolean | { version: number }
  }
}

export function persistPlugin({ store, options }: PiniaPluginContext) {
  if (!options.persist || typeof window === 'undefined') return

  const version = typeof options.persist === 'object' ? options.persist.version : 1
  const key = `pinia:${store.$id}`

  const saved = localStorage.getItem(key)
  if (saved) {
    try {
      const parsed = JSON.parse(saved) as { v: number, data: StateTree }
      if (parsed.v === version) store.$patch(parsed.data)
      else localStorage.removeItem(key)
    } catch {
      localStorage.removeItem(key)
    }
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify({ v: version, data: state }))
  }, { detached: true })
}

The saved value is now { v, data }, and a store with persist: true counts as version 1. When the cart's shape changes, bump the store to { persist: { version: 2 } } and old data is dropped on the next visit. If dropping is too harsh for a store, add a migrate(old) function to the option and call it instead of removeItem.

When to use pinia-plugin-persistedstate instead

pinia-plugin-persistedstate does the same job, ready-made, and 4.5 is the current release. Install it and register it the way you registered persistPlugin:

npm install pinia-plugin-persistedstate
src/main.ts
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

pinia.use(piniaPluginPersistedstate)

A store opts in with the same persist: true, and the package's configuration adds the options you would end up writing yourself:

  • key: the storage key, store.$id by default.
  • storage: localStorage by default, and sessionStorage is a one-word swap. A custom storage must be synchronous.
  • pick and omit: persist only part of the state, with dot paths.
  • serializer: for anything JSON cannot hold.
  • beforeHydrate and afterHydrate: hooks that run before and after the restore.

It also ships a Nuxt module, which stores state in cookies by default because the server can read them too. A cookie holds only about 4 KB, so keep those stores small.

What it does not have is a version number, so the migration problem from step 5 is still yours. The package is the right default for a team. The hand-written plugin is right when you want versioning, an asynchronous storage such as IndexedDB, or simply no dependency.

If what you want to keep is one value rather than a store, neither is the tool. VueUse's useStorage() binds a single ref to storage in one line, and an earlier post on this blog covers it.

What not to persist

Persisting is easy, which is why it is overused. Four things should stay in memory:

  • Tokens and anything secret. localStorage is readable by any script on the page, so a cross-site scripting bug turns into a stolen session. Keep tokens in an httpOnly cookie set by the server.
  • Server data. A product list copied into storage is stale the moment the server changes. Refetch it; cache it with the fetching layer if you must.
  • Large lists. Browsers give an origin a few megabytes of localStorage, writes are synchronous, and the plugin serialises the whole state on every change. A thousand-row table persisted on each keystroke will make the page stutter.
  • Values JSON cannot represent. A Date becomes a string and a Map becomes {}. Store the ISO string or the array, or give the plugin a serializer.

What is left is small, user-owned and cheap to rebuild: the cart, a draft, the chosen theme, the last open tab.

Check it stuck3 questions from the Persisting state and plugins quiz.
Practice now
Was this helpful?

Keep reading