skip to content

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?

level: seniorimportance: should knowfreq 37%

answer

  1. a custom defineStore option
  2. read it from context.options
  3. third argument for setup stores
  4. restore with $patch, save on $subscribe
  5. augment DefineStoreOptionsBase

basics

~20 s

Stores 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 s

Pinia 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 lines
ts
import { 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

for a junior

Recall that Pinia plugins can read custom options passed to defineStore(), which lets stores opt into behaviour such as persistence.

for a middle

Explain where the option goes for option and setup stores, and how the plugin restores with $patch and saves with $subscribe.

for a senior

Handle the production edges: the server-rendering guard, corrupt saved data, storage write failures, restore-before-subscribe ordering and typing through DefineStoreOptionsBase.

for a principal

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