Persisting and Hydrating
Saving a store to local storage, choosing what is safe to keep, and reading it back before the first paint instead of after it.
pinia.use(({ store }) => { /* load, then store.$subscribe to save */ })Persisting means writing part of a store to localStorage so it survives a reload. Hydrating is the other half: putting that saved value back into the store when the app starts. Two pieces, and most bugs are in the second.
Saving
Either write it yourself, with a watch in the store or a $subscribe that serialises state, or install a Pinia plugin: a function given { pinia, app, store, options } for every store, registered with pinia.use(fn). One plugin covers every store, which is why pinia-plugin-persistedstate exists. In version 4 of that plugin you choose fields with pick and omit; older guides show a paths option instead.
What is safe to persist
Preferences, a draft form, a cart, which panel was open. Not tokens or anything else secret: any script on the origin can read local storage, and the user can edit it. Not server data either, since the saved copy has no expiry. Treat what you read back as untrusted input and validate it.
Hydrating without a flash
Read storage while the store is being created, synchronously. Doing it in onMounted means the first paint shows the default and then visibly changes. With server rendering there is no localStorage on the server, so a value read during setup makes the client markup differ from the server's and Vue logs a hydration mismatch; apply it after hydration, or set it from a small script in index.html before the app boots.
What trips people up
Version the key. Old JSON in a new shape patches nonsense into state.
Example
<script setup lang="ts">
import { useSettingsStore } from '@/stores/settings'
const settings = useSettingsStore()
</script>
<template>
<label class="row">
<!-- Shorthand for :checked plus an @change that assigns the new value -->
<input v-model="settings.darkMode" type="checkbox" />
Dark mode
</label>
</template>
<style scoped>
.row {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>When to use it
- Remembering a theme or language choice across reloads without a server round trip.
- Keeping a long form's draft so a reload does not lose the user's typing.
- Restoring a cart for a signed-out visitor while leaving their session token out of storage.
More examples
Load at creation, save on change
The whole pattern for one store, with no plugin. loadDarkMode runs while the ref is created, which is before any component renders, so there is no flash. It guards against a missing localStorage so the file also runs on a server, parses inside try/catch because the string may be anything, and keeps the version in the key so a future shape change cannot be read as the current one.
// src/stores/settings.ts
import { ref, watch } from 'vue'
import { defineStore } from 'pinia'
const KEY = 'settings.v1'
function loadDarkMode(): boolean {
if (typeof localStorage === 'undefined') {
return false
}
try {
const raw = localStorage.getItem(KEY)
const parsed = JSON.parse(raw ?? '{}') as { darkMode?: unknown }
// Validate: storage is user-editable and the shape may be old.
return parsed.darkMode === true
} catch {
return false
}
}
export const useSettingsStore = defineStore('settings', () => {
const darkMode = ref(loadDarkMode())
watch(darkMode, (value) => {
localStorage.setItem(KEY, JSON.stringify({ darkMode: value }))
})
return { darkMode }
})A persistence plugin for every store
The plugin runs once per store, as that store is created, so the $patch lands before anything renders it. Written like this it persists every store, which is rarely what you want: gate it on store.$id, or read a flag from the context's options. Plugins only apply to stores created after pinia.use(), so register them before mounting.
// src/main.ts
import { createApp, toRaw } from 'vue'
import { createPinia, type PiniaPluginContext } from 'pinia'
import App from './App.vue'
function persist({ store }: PiniaPluginContext) {
const key = `pinia.${store.$id}`
const saved = localStorage.getItem(key)
if (saved !== null) {
store.$patch(JSON.parse(saved))
}
store.$subscribe(
(_mutation, state) => {
localStorage.setItem(key, JSON.stringify(toRaw(state)))
},
{ detached: true },
)
}
const pinia = createPinia()
pinia.use(persist)
createApp(App).use(pinia).mount('#app')The same thing with pinia-plugin-persistedstate
For a setup store the plugin options are the third argument to defineStore; in an option store, persist sits beside state and actions. pick names exactly what is saved, so draftComment stays in memory only. The storage you pass has to be synchronous, which rules out anything promise-based such as IndexedDB.
// src/stores/settings.ts
// npm install pinia-plugin-persistedstate
// in main.ts: pinia.use(piniaPluginPersistedstate)
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore(
'settings',
() => {
const darkMode = ref(false)
const draftComment = ref('')
return { darkMode, draftComment }
},
{
persist: {
key: 'settings.v1',
storage: localStorage,
pick: ['darkMode'],
},
},
)
Discussion