| |

Vue.js 54 🟢 Writing Custom Pinia Plugins and Hydration Hooks

⏱️ Estimated read: 14 min | |

Pinia’s plugin system is the escape hatch that turns a store library into an extensible framework. A plugin is a function that receives a context object—containing the Pinia instance, the Vue app, the store being augmented, and the options passed to defineStore()—and can return an object of properties to add to the store, or mutate the store directly . This is how persistence libraries, logger plugins, and cross-store utilities are built.

The plugin mechanism is deliberately low-level. It does not prescribe what a plugin should do; it only provides the hooks. You can add static properties, inject new state, wrap actions, subscribe to changes, or apply logic only to specific stores based on their $id . The constraint is that plugins only apply to stores created after the plugin is installed with pinia.use() . This means plugin registration must happen before any useStore() call in the application lifecycle.

Hydration is a separate but related concern. When server-side rendering (SSR) is used, the server serializes the Pinia state into the HTML, and the client must restore it before any store is used . The hydrate() option in defineStore() handles the case where a store contains state that cannot be simply copied from pinia.state—custom refs, client-only values, or composables like useLocalStorage . For Setup Stores, skipHydrate() marks specific properties that should not be picked up during hydration, typically because they are not really state .

This chapter covers plugin creation, the context object, adding state correctly for SSR, the hydrate() hook, and the skipHydrate() escape hatch.

Key point: A Pinia plugin is a function registered with pinia.use() that receives { pinia, app, store, options } and can return properties to augment the store; plugins only apply to stores created after registration. The hydrate() option handles SSR restoration of complex state, while skipHydrate() excludes non-state values from hydration in Setup Stores.


Why plugins and hydration hooks exist

The cross-cutting concern problem. Persistence, logging, and analytics are not store-specific. Every store in an application might need to be saved to localStorage, or every action might need to be timed. Without plugins, each store would have to duplicate this logic, or each component would have to set up subscriptions. Plugins centralize these concerns: write the logic once, register it once, and every store created afterward inherits it .

The SSR state mismatch. In SSR, the server renders the page and serializes pinia.state.value into the HTML. On the client, this state is parsed and assigned to pinia.state.value before any useStore() call . For plain data, this works automatically. But if a store’s state contains a useLocalStorage ref, or a customRef, or a computed value that differs between server and client, simply copying the serialized state is not enough. The hydrate() hook exists to manually restore these values .

The Setup Store hydration ambiguity. Setup Stores return an object containing refs, computeds, and functions. Pinia treats every returned ref as state and tries to hydrate it. But some returned values are not state—a router instance, a composable that manages its own lifecycle, or a useLocalStorage ref that has its own persistence mechanism. skipHydrate() marks these values so Pinia leaves them alone during the hydration process .

The plugin ordering constraint. A plugin only applies to stores created after pinia.use() is called. If a store is instantiated before the plugin is registered—for example, in a module-level useStore() call—the plugin never touches it . This is why plugin registration must happen in main.js or main.ts immediately after createPinia(), before the app is mounted and before any component calls useStore().

The devtools visibility problem. Properties added to a store via plugin are not automatically visible in Vue devtools. To make them appear, you must add the property name to store._customProperties in development mode . This is a small but important detail for plugin authors who want their additions to be debuggable.


a. Writing a plugin: context, return values, and direct mutation

A plugin is a function that receives a PiniaPluginContext and optionally returns an object. The context contains four properties: pinia (the Pinia instance), app (the Vue app), store (the store being extended), and options (the object passed to defineStore()) .

The simplest plugin returns an object of static properties:

import { createPinia } from 'pinia'

function SecretPiniaPlugin() {
  return { secret: 'the cake is a lie' }
}

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

const store = useStore()
store.secret // 'the cake is a lie'

The returned object’s properties are added to every store created after registration. This is useful for injecting global services—the router, a toast manager, a modal controller—into every store .

You can also mutate the store directly instead of returning an object:

pinia.use(({ store }) => {
  store.hello = 'world'
})

The difference is devtools visibility. Properties returned from the plugin are automatically tracked by devtools. Properties set directly on the store require manual registration in _customProperties to appear in devtools .

Adding state is more involved. State must be added to both store (for access) and store.$state (for SSR serialization and devtools). The correct pattern uses ref() and checks that the property does not already exist, to avoid overwriting server-rendered values during hydration:

import { ref } from 'vue'

pinia.use(({ store }) => {
  if (!store.$state.hasOwnProperty('hasError')) {
    const hasError = ref(false)
    store.$state.hasError = hasError
  }
  store.hasError = store.$state.hasError
})

This pattern ensures SSR works: the server serializes hasError from $state, and the client hydrates it before the plugin runs, so the hasOwnProperty check prevents overwriting the hydrated value .

To apply a plugin only to a specific store, check store.$id:

pinia.use(({ store }) => {
  if (store.$id === 'userInfo') {
    store.customState = 'customState'
  }
})

This is useful for store-specific concerns that do not belong everywhere .


b. Hydration hooks: hydrate() and skipHydrate()

The hydrate() option is part of defineStore() for Option Stores. It is called during SSR hydration when the default copy from pinia.state is insufficient. It receives two arguments: storeState (the current state in the store) and initialState (the state serialized from the server) .

The canonical use case is a store that uses useLocalStorage for a value that should be client-specific:

const useStore = defineStore('main', {
  state: () => ({
    n: useLocalStorage('key', 0)
  }),
  hydrate(storeState, initialState) {
    storeState.n = useLocalStorage('key', 0)
  }
})

Without hydrate(), the serialized value from the server would overwrite the client’s local storage value, which may be different. The hydrate() hook restores the client-specific value .

For Setup Stores, the equivalent problem is solved with skipHydrate(). When you return a value from a Setup Store that is not really state—a router instance, a composable’s internal ref—Pinia would try to hydrate it by default. skipHydrate() marks it to be excluded:

import { skipHydrate } from 'pinia'

export const useStore = defineStore('main', () => {
  const router = useRouter()
  return { router: skipHydrate(router) }
})

The official documentation is explicit: skipHydrate() is for Setup Stores only, and it is used when a returned stateful object “isn’t really state” . Without it, the router or composable would be treated as serializable state and could cause hydration mismatches.


c. Plugin patterns: persistence, logging, and wrapping actions

The most common plugin is persistence. The pattern is to subscribe to state changes and write the state to localStorage on every mutation. A plugin centralizes this so every store is persisted automatically:

pinia.use(({ store }) => {
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`store-${store.$id}`, JSON.stringify(state))
  }, { detached: true })
})

The detached: true option is essential here. Without it, the subscription would be tied to whatever component context the plugin runs in—and since the plugin runs outside a component, the behavior is undefined. detached: true makes the subscription independent of any component lifecycle .

Logging plugins use both $subscribe() and $onAction() to capture state changes and action invocations. A well-structured logger records the action name, arguments, duration, and result, and logs state mutations separately because a state change can happen without an action (via $patch) and an action can happen without a state change (if it only returns a value) .

Wrapping actions is another pattern. You can intercept an action by replacing it with a wrapper that calls the original:

pinia.use(({ store }) => {
  const originalAction = store.someAction
  store.someAction = async (...args) => {
    console.log('before')
    const result = await originalAction.apply(store, args)
    console.log('after')
    return result
  }
})

This is more invasive than $onAction() and should be used sparingly, but it allows modifying arguments or the return value, which $onAction() cannot do .

A reset plugin for Setup Stores is a practical example of plugin utility. Since Setup Stores do not have a built-in $reset(), a plugin can capture the initial state with toRaw() and provide a reset method:

pinia.use(({ store }) => {
  const initialState = JSON.parse(JSON.stringify(toRaw(store.$state)))
  return {
    $reset() {
      store.$patch(initialState)
    }
  }
})

This demonstrates the plugin system’s ability to fill gaps in the core API .


Complete Example Session

The following session builds a persistence plugin, a logger plugin, and demonstrates hydrate() and skipHydrate().

// ============================================
// PART 1: PERSISTENCE PLUGIN
// ============================================
import { createPinia } from 'pinia'

function persistPlugin({ store }) {
  // Restore from localStorage on creation
  const saved = localStorage.getItem(`pinia-${store.$id}`)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // Save on every state change
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
  }, { detached: true })
}

// ============================================
// PART 2: LOGGER PLUGIN
// ============================================
function loggerPlugin({ store }) {
  store.$onAction(({ name, args, after, onError }) => {
    const start = Date.now()
    console.log(`[${store.$id}] ${name} called with`, args)

    after((result) => {
      console.log(`[${store.$id}] ${name} completed in ${Date.now() - start}ms`)
    })

    onError((error) => {
      console.error(`[${store.$id}] ${name} failed:`, error)
    })
  })
}

// ============================================
// PART 3: PLUGIN REGISTRATION
// ============================================
const pinia = createPinia()
pinia.use(persistPlugin)
pinia.use(loggerPlugin)

// ============================================
// PART 4: OPTION STORE WITH hydrate()
// ============================================
import { defineStore } from 'pinia'
import { useLocalStorage } from '@vueuse/core'

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    theme: useLocalStorage('theme', 'light')
  }),
  hydrate(storeState, initialState) {
    // Restore client-specific local storage value
    storeState.theme = useLocalStorage('theme', 'light')
  },
  actions: {
    toggleTheme() {
      this.theme = this.theme === 'light' ? 'dark' : 'light'
    }
  }
})

// ============================================
// PART 5: SETUP STORE WITH skipHydrate()
// ============================================
import { ref } from 'vue'
import { skipHydrate } from 'pinia'
import { useRouter } from 'vue-router'

export const useAppStore = defineStore('app', () => {
  const sidebarOpen = ref(false)
  const router = useRouter() // Not state; skip hydration

  return {
    sidebarOpen,
    router: skipHydrate(router)
  }
})

// ============================================
// PART 6: STORE-SPECIFIC PLUGIN
// ============================================
function userSpecificPlugin({ store }) {
  if (store.$id === 'user') {
    store.lastActivity = Date.now()
  }
}

// ============================================
// PART 7: DEVTOOLS VISIBILITY
// ============================================
function devtoolsPlugin({ store }) {
  store.customFlag = true
  if (process.env.NODE_ENV === 'development') {
    store._customProperties.add('customFlag')
  }
}

// ============================================
// PART 8: USAGE
// ============================================
// After app.use(pinia)
const settings = useSettingsStore()
settings.toggleTheme() // Logger fires, persistence saves

// ============================================
// PART 9: PLUGIN ORDERING NOTE
// ============================================
// Plugins only apply to stores created after pinia.use()
// Register all plugins before mounting the app or calling useStore()

// ============================================
// PART 10: SSR HYDRATION SETUP (main.js)
// ============================================
// Server:
// const pinia = createPinia()
// app.use(pinia)
// const html = await renderToString(app)
// const state = devalue(pinia.state.value)

// Client:
// const pinia = createPinia()
// app.use(pinia)
// pinia.state.value = JSON.parse(window.__pinia)
// Then mount app — stores hydrate automatically

The ten parts cover persistence and logger plugins, registration, hydrate() in an Option Store, skipHydrate() in a Setup Store, store-specific plugins, devtools visibility, usage, the ordering constraint, and SSR hydration setup. What the session demonstrates is that plugins are the mechanism for cross-cutting store concerns, while hydrate() and skipHydrate() handle the edge cases where default SSR state restoration is insufficient.


Quick Reference

Plugin Context Properties

PropertyMeaning
piniaThe createPinia() instance
appThe Vue app (Vue 3 only)
storeThe store being extended
optionsThe defineStore() options object

Plugin Return vs Direct Set

ApproachDevtools Tracking
return { prop: value }Automatic
store.prop = valueManual via _customProperties

Hydration Hooks

HookStore TypePurpose
hydrate()Option StoreManual SSR restoration
skipHydrate()Setup StoreExclude non-state values

Adding State in a Plugin

LocationRequired For
store.$state.propSSR serialization, devtools
store.propComponent access

Plugin Ordering

RuleConsequence
Plugin registered after store createdPlugin does not apply
Plugin registered before store createdPlugin applies

Best Practices

✅ Do This:

pinia.use(persistPlugin);                                    // ✅ before app mount
store.$state.newProp = ref(0); store.newProp = store.$state.newProp; // ✅ SSR-safe
store.$subscribe(cb, { detached: true });                    // ✅ plugin subscription
storeState.n = useLocalStorage('key', 0);                    // ✅ hydrate client value
return { router: skipHydrate(router) };                      // ✅ setup store exclusion

❌ Don’t Do This:

const store = useStore(); pinia.use(plugin);                 // ❌ store created first
store.newProp = ref(0); // only on store                   // ❌ breaks SSR
store.$subscribe(cb); // no detached                       // ❌ tied to no component
// return router without skipHydrate                       // ❌ hydration mismatch

Common Pitfalls

PitfallWhy It HappensFix
Plugin not appliedStore created before pinia.use()Register plugins before app mount
State missing in SSRAdded only to store, not $stateAdd to both
Persistence breaks on refreshSubscription not detachedUse { detached: true }
Router hydrated as stateNot wrapped in skipHydrate()Use skipHydrate() in Setup Store
hydrate() not calledStore is a Setup StoreUse skipHydrate() instead
Devtools missing plugin propNot in _customPropertiesAdd in dev mode

Real-World Examples

1. Persistence plugin

pinia.use(({ store }) => {
  store.$subscribe((_, state) => localStorage.setItem(store.$id, JSON.stringify(state)), { detached: true })
})

2. Logger plugin

pinia.use(({ store }) => {
  store.$onAction(({ name, after, onError }) => { /* log */ })
})

3. Inject router into every store

pinia.use(({ app }) => ({ router: app.config.globalProperties.$router }))

4. Store-specific plugin

pinia.use(({ store }) => { if (store.$id === 'auth') store.isReady = false })

5. Add state with SSR safety

if (!store.$state.hasOwnProperty('error')) { store.$state.error = ref(null); store.error = store.$state.error }

6. Hydrate local storage value

hydrate(storeState) { storeState.theme = useLocalStorage('theme', 'light') }

7. Skip router hydration

return { router: skipHydrate(router) }

8. Reset plugin for Setup Stores

pinia.use(({ store }) => { const init = toRaw(store.$state); return { $reset: () => store.$patch(init) } })

9. Devtools custom property

if (process.env.NODE_ENV === 'development') store._customProperties.add('hello')

10. SSR state restoration

// client
pinia.state.value = JSON.parse(window.__pinia)

Visual

Plugin Lifecycle

┌──────────────────────────────────────────────┐
│  createPinia()                               │
│         │                                    │
│         ▼                                    │
│  pinia.use(pluginA)                          │
│  pinia.use(pluginB)                          │
│         │                                    │
│         ▼                                    │
│  app.use(pinia)                              │
│         │                                    │
│         ▼                                    │
│  Component calls useStore()                  │
│         │                                    │
│         ▼                                    │
│  Store created                               │
│         │                                    │
│         ▼                                    │
│  pluginA({ pinia, app, store, options })     │
│  pluginB({ pinia, app, store, options })     │
│         │                                    │
│         ▼                                    │
│  Store augmented                             │
└──────────────────────────────────────────────┘

Adding State Correctly

┌──────────────────────────────────────────────┐
│  PLUGIN                                      │
│  if (!store.$state.hasOwnProperty('error')) {│
│    store.$state.error = ref(null)  ← SSR     │
│  }                                           │
│  store.error = store.$state.error  ← access  │
│                                              │
│  WHY BOTH?                                   │
│  store.$state → serialized during SSR,       │
│                 visible in devtools          │
│  store        → accessible as store.error    │
└──────────────────────────────────────────────┘

Hydration Flow (SSR)

┌──────────────────────────────────────────────┐
│  SERVER                                      │
│  pinia.state.value = { settings: { theme: 'light' } } │
│  devalue(pinia.state.value)                  │
│         │                                    │
│         ▼                                    │
│  HTML with serialized state                  │
│         │                                    │
│         ▼                                    │
│  CLIENT                                      │
│  pinia.state.value = JSON.parse(window.__pinia) │
│         │                                    │
│         ▼                                    │
│  useStore() called                           │
│         │                                    │
│         ▼                                    │
│  Default hydration: copy pinia.state → store │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  simple    complex                           │
│  state     state                             │
│    │         │                               │
│    ▼         ▼                               │
│  done    hydrate() / skipHydrate()           │
└──────────────────────────────────────────────┘

skipHydrate vs hydrate

┌──────────────────────────────────────────────┐
│  OPTION STORE                                │
│  state: () => ({ n: useLocalStorage('k', 0) })│
│  hydrate(storeState) {                       │
│    storeState.n = useLocalStorage('k', 0)    │
│  }                                           │
│  → Manual restoration of client value        │
│                                              │
│  SETUP STORE                                 │
│  const router = useRouter()                  │
│  return { router: skipHydrate(router) }      │
│  → Exclude from hydration entirely           │
└──────────────────────────────────────────────┘

Summary

ItemValue
Plugin function(context) => object | void
Registrationpinia.use(plugin)
Context{ pinia, app, store, options }
OrderingPlugins apply only to stores created after registration
Add stateBoth store.$state and store
Devtoolsstore._customProperties.add(key)
hydrate()Option Store SSR restoration
skipHydrate()Setup Store exclusion
Detached subscription{ detached: true }

Key takeaways:

  • Plugins are functions registered with pinia.use(). They receive the Pinia instance, Vue app, store, and store options, and can return properties to augment the store .
  • Ordering matters. A plugin only applies to stores created after it is registered. Register plugins before mounting the app or calling useStore() .
  • Adding state requires two assignments. Set it on store.$state for SSR serialization and devtools, and on store for component access .
  • hydrate() handles complex SSR state. Use it when a store contains custom refs or client-only values that cannot be copied from pinia.state .
  • skipHydrate() excludes non-state values. In Setup Stores, wrap router instances or composable internals with skipHydrate() to prevent hydration mismatches .
  • Detached subscriptions survive context. Use { detached: true } for plugin subscriptions that are not tied to a component lifecycle .
  • Devtools needs manual registration. Properties set directly on the store must be added to _customProperties to appear in devtools .

Remember: Pinia plugins are the extension mechanism for cross-cutting store concerns. Write a function, receive the context, return properties or mutate the store, and register it with pinia.use() before any store is created. When adding state, touch both $state and the store itself. For SSR, use hydrate() in Option Stores for values that need manual restoration, and skipHydrate() in Setup Stores for values that are not state. The plugin system is intentionally minimal—it gives you the hooks and leaves the logic to you.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!