How would you build a Pinia plugin that persists only stores declaring a persist option, such as a theme store and a form draft, and type that option?
answer
- a custom defineStore option
- read it from context.options
- third argument for setup stores
- restore with $patch, save on $subscribe
- augment DefineStoreOptionsBase
basics
~20 sStores opt in with a custom persist option, the third defineStore argument for setup stores; the plugin reads options.persist, restores saved state with $patch, saves on $subscribe, skips the server, and DefineStoreOptionsBase types the option.
solid answer
~40 sPinia lets a store carry options it does not know: an option store puts `persist: 'local'` beside `state` and `actions`, a setup store passes `{ persist: 'session' }` as the third argument of `defineStore()`. A plugin sees them in `context.options`. Mine returns early unless `options.persist` is set and `window` exists (plugins also run during server rendering). It builds a key from `store.$id`, restores saved JSON with `store.$patch()`, then calls `store.$subscribe()` to write `JSON.stringify(state)` on each change, with a `try`/`catch` around parsing. Only `$state` is persisted, so a setup store must return every ref it wants saved. To type the option for both store kinds I augment `DefineStoreOptionsBase<S, Store>`, keeping those generic names exactly as Pinia declares them.
code
ts · 19 linesimport { ref } from 'vue'
import { createPinia, defineStore } from 'pinia'
import { persistPlugin } from './persist-plugin'
export const pinia = createPinia()
pinia.use(persistPlugin)
// stores/theme.ts
export const useThemeStore = defineStore('theme', {
state: () => ({ mode: 'light' as 'light' | 'dark' }),
persist: 'local',
})
// stores/draft.ts
export const useDraftStore = defineStore('draft', () => {
const title = ref('')
const body = ref('')
return { title, body }
}, { persist: 'session' })go deeper
Recall that Pinia plugins can read custom options passed to defineStore(), which lets stores opt into behaviour such as persistence.
Explain where the option goes for option and setup stores, and how the plugin restores with $patch and saves with $subscribe.
Handle the production edges: the server-rendering guard, corrupt saved data, storage write failures, restore-before-subscribe ordering and typing through DefineStoreOptionsBase.
Set the persistence policy: which data may live in browser storage, how saved shapes evolve across releases, and when persistence belongs on the server instead.
## The design A persistence plugin should not save every store: a theme preference belongs in long-lived storage, a half-written form draft in the tab's session, and most stores in neither. The clean shape is **opt-in by option**: 1. The store author declares a custom option, here `persist: 'local' | 'session'`. 2. A plugin registered with `pinia.use()` reads that option from its context and ignores stores without it. 3. On creation, the plugin restores saved state; afterwards, it saves on every change. 4. A type augmentation makes the option legal in both option and setup stores. Pinia supports this directly: `defineStore()` passes unknown options through to plugins, which receive them as `context.options`. ## Declaring the option | Store kind | Where the option goes | |---|---| | Option store | beside `state`, `getters` and `actions` in the options object | | Setup store | in the third argument, after the setup function | ```ts export const useThemeStore = defineStore('theme', { state: () => ({ mode: 'light' as 'light' | 'dark' }), persist: 'local', }) export const useDraftStore = defineStore('draft', () => { const title = ref('') const body = ref('') return { title, body } }, { persist: 'session' }) ``` The setup store must **return every ref** it wants persisted: only returned refs become part of `$state`, and the plugin saves `$state`. ## Typing the option Pinia exposes three interfaces for `defineStore()` options, and the choice decides where the option is accepted: | Interface | Applies to | Generics | |---|---|---| | `DefineStoreOptionsBase` | both option and setup stores | `S`, `Store` | | `DefineStoreOptions` | option stores only | `Id`, `S`, `G`, `A` | | `DefineSetupStoreOptions` | setup stores only | `Id`, `S`, `G`, `A` | For an option both kinds use, augment `DefineStoreOptionsBase`. The generic parameters must keep the exact names from Pinia's source (`S` and `Store`); renaming them breaks the declaration merge. The base interface also flows into the `options` a plugin receives, so `options.persist` is typed inside the plugin. ## The plugin ```ts import 'pinia' import type { PiniaPluginContext } from 'pinia' declare module 'pinia' { export interface DefineStoreOptionsBase<S, Store> { persist?: 'local' | 'session' } } export function persistPlugin({ store, options }: PiniaPluginContext) { if (!options.persist || typeof window === 'undefined') return const storage = options.persist === 'local' ? localStorage : sessionStorage const key = `pinia:${store.$id}` const saved = storage.getItem(key) if (saved) { try { store.$patch(JSON.parse(saved)) } catch { storage.removeItem(key) } } store.$subscribe((_mutation, state) => { storage.setItem(key, JSON.stringify(state)) }) } ``` Why it is written this way: - **Restore before subscribing.** `$patch()` calls existing `$subscribe()` callbacks directly, so subscribing first would write the just-loaded data straight back. - **`$patch()` merges** the saved object into state, so a field added to `state()` after a user's data was saved keeps its default. - **The subscription lives with the store.** Pinia runs plugins inside the store's own effect scope, so the callback is not removed when the first component using the store unmounts. - **The key uses `store.$id`**, which is unique per store and stable across deploys unless someone renames the store. ## What else it must handle - **Server rendering.** Plugins run for stores created on the server too, where there is no Web Storage, hence the `window` check. The server then renders the default theme and the client restores the saved one after hydration, which can flash; if that matters, the theme has to reach the server, for example in a cookie. - **Bad data.** Saved JSON can be corrupt or from an old shape; the `try`/`catch` drops it rather than breaking the store. - **Storage failures.** `setItem()` can throw, for instance when storage is full or blocked; those rules belong to the Web Storage topic, and a production plugin wraps the write too. - **What not to persist.** Tokens and personal data do not belong in `localStorage`; opt-in keeps that decision visible in each store. ## Why a plugin rather than per-store code Writing a `watch()` in each store works for one store and drifts for five. A plugin centralises the key format, the error handling and the SSR guard, while the option keeps the decision next to the store that owns the data.
- A setup store keeps a private ref it never returns; why does the persistence plugin never save it?The plugin saves `$state`, and in a setup store only the refs the setup function returns become state. A private ref is invisible to Pinia, so it is not in `$state`, not in SSR state and not seen by devtools or plugins. Return it, or accept that it is not persisted.
- Why would augmenting DefineStoreOptions instead of DefineStoreOptionsBase break the draft store?`DefineStoreOptions` is the options type of option stores only. The draft store is a setup store whose third argument is typed by `DefineSetupStoreOptions`, so `persist` would be a type error there. `DefineStoreOptionsBase` is the shared parent of both.
saying these in an interview costs you the question
- A setup store passes custom options by returning them from setup
- pinia.use(plugin, { persist: true }) configures a plugin per store
- DefineStoreOptions covers custom options for setup stores as well
- Pinia never runs plugins on the server, so no window check is needed
- Subscribing before restoring is harmless because $patch never calls subscribers