Vue.js 54 🟢 Writing Custom Pinia Plugins and Hydration Hooks
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
| Property | Meaning |
|---|---|
pinia | The createPinia() instance |
app | The Vue app (Vue 3 only) |
store | The store being extended |
options | The defineStore() options object |
Plugin Return vs Direct Set
| Approach | Devtools Tracking |
|---|---|
return { prop: value } | Automatic |
store.prop = value | Manual via _customProperties |
Hydration Hooks
| Hook | Store Type | Purpose |
|---|---|---|
hydrate() | Option Store | Manual SSR restoration |
skipHydrate() | Setup Store | Exclude non-state values |
Adding State in a Plugin
| Location | Required For |
|---|---|
store.$state.prop | SSR serialization, devtools |
store.prop | Component access |
Plugin Ordering
| Rule | Consequence |
|---|---|
| Plugin registered after store created | Plugin does not apply |
| Plugin registered before store created | Plugin 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plugin not applied | Store created before pinia.use() | Register plugins before app mount |
| State missing in SSR | Added only to store, not $state | Add to both |
| Persistence breaks on refresh | Subscription not detached | Use { detached: true } |
| Router hydrated as state | Not wrapped in skipHydrate() | Use skipHydrate() in Setup Store |
hydrate() not called | Store is a Setup Store | Use skipHydrate() instead |
| Devtools missing plugin prop | Not in _customProperties | Add 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
| Item | Value |
|---|---|
| Plugin function | (context) => object | void |
| Registration | pinia.use(plugin) |
| Context | { pinia, app, store, options } |
| Ordering | Plugins apply only to stores created after registration |
| Add state | Both store.$state and store |
| Devtools | store._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.$statefor SSR serialization and devtools, and onstorefor component access . hydrate()handles complex SSR state. Use it when a store contains custom refs or client-only values that cannot be copied frompinia.state.skipHydrate()excludes non-state values. In Setup Stores, wrap router instances or composable internals withskipHydrate()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
_customPropertiesto 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.
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!