Vue 3 Pinia Plugin System: 5 Core Patterns for Custom Store Plugins
Introduction
Have you encountered these scenarios: manually writing localStorage persistence logic for every Store, custom debug information invisible in DevTools, and loading/error states for network requests scattered across components? Pinia provides a clean Store API, but its plugin system is often overlooked — and that's precisely the key to solving these pain points.
The Pinia plugin system lets you inject common logic when any Store is created, without modifying individual Store definitions. This article walks you through 5 core patterns to build production-grade Pinia plugins from scratch.
Core Concepts at a Glance
| Concept | Description |
|---|---|
| Pinia Plugin | A function executed when a Store is created, extending Store functionality |
| PiniaPlugin interface | ({ store }) => void, the plugin function signature |
| store.$onAction | Subscribe to all action invocations in a Store |
| store.$subscribe | Subscribe to all state changes in a Store |
| Persistence | Automatically syncing Store state to a storage medium |
| DevTools | Vue browser dev tools for debugging Store state |
| Plugin Registration | Register global plugins via pinia.use() |
| Store Extension | Injecting new state/getter/action into a Store via plugins |
Problem Analysis: 5 Major Challenges with Pinia Plugins
1. Manual Persistence Implementation: Every Store needing persistence requires repetitive localStorage read/write logic, plus handling serialization and version migration edge cases.
2. Difficult DevTools Integration: Custom Store data displays poorly in DevTools, making it impossible to track plugin-injected state changes during debugging.
3. Unfamiliar Plugin API: The invocation timing and parameter structure of hooks like $onAction and $subscribe are unintuitive and easily misused.
4. Missing Store Lifecycle Hooks: Pinia lacks a Store destruction hook, making it hard to automatically clean up side effects registered by plugins.
5. Plugin Composition Conflicts: When multiple plugins modify a Store simultaneously, property overwrites and execution order issues are hard to predict.
Pattern 1: Basic Pinia Plugin Structure
import type { PiniaPluginContext } from 'pinia'
interface PluginOptions {
prefix?: string
}
export function createLoggerPlugin(options: PluginOptions = {}) {
const { prefix = '[Pinia]' } = options
return ({ store }: PiniaPluginContext) => {
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`${prefix} ${store.$id}/${name} started`, args)
after((result) => {
console.log(
`${prefix} ${store.$id}/${name} finished (${Date.now() - startTime}ms)`,
result
)
})
onError((error) => {
console.error(
`${prefix} ${store.$id}/${name} failed`,
error
)
})
})
}
}
// Registration
const pinia = createPinia()
pinia.use(createLoggerPlugin({ prefix: '[App]' }))
Pattern 2: Persistence Plugin Implementation
interface PersistenceOptions {
key?: string
storage?: Storage
paths?: string[]
serializer?: {
serialize: (value: unknown) => string
deserialize: (value: string) => unknown
}
}
declare module 'pinia' {
export interface DefineStoreOptionsBase<S> {
persistence?: PersistenceOptions
}
}
export function createPersistencePlugin() {
return ({ store, options }: PiniaPluginContext) => {
const config = options.persistence
if (!config) return
const {
key = store.$id,
storage = localStorage,
paths,
serializer = {
serialize: JSON.stringify,
deserialize: JSON.parse,
},
} = config
const hydrate = () => {
try {
const raw = storage.getItem(key)
if (raw) {
const data = serializer.deserialize(raw)
if (paths) {
paths.forEach((path) => {
if (path in data) {
store.$patch({ [path]: data[path] })
}
})
} else {
store.$patch(data)
}
}
} catch (e) {
console.error(`[PiniaPersistence] Failed to hydrate ${key}:`, e)
}
}
hydrate()
store.$subscribe((_mutation, state) => {
try {
const data = paths
? paths.reduce((acc, path) => {
acc[path] = state[path]
return acc
}, {} as Record<string, unknown>)
: state
storage.setItem(key, serializer.serialize(data))
} catch (e) {
console.error(`[PiniaPersistence] Failed to persist ${key}:`, e)
}
})
}
}
// Usage
export const useUserStore = defineStore('user', {
state: () => ({ token: '', preferences: {} }),
persistence: {
paths: ['token', 'preferences'],
storage: sessionStorage,
},
})
Pattern 3: DevTools Integration Plugin
export function createDevToolsPlugin() {
return ({ store, app }: PiniaPluginContext) => {
if (import.meta.env.DEV) {
store._customProperties = new Set()
const original$patch = store.$patch.bind(store)
store.$patch = function (...args: unknown[]) {
const snapshot = JSON.parse(JSON.stringify(store.$state))
console.groupCollapsed(`[DevTools] ${store.$id}.$patch`)
console.table({ before: snapshot, after: 'pending...' })
console.groupEnd()
return original$patch(...args)
}
app.config.globalProperties.$piniaDevTools = {
getStoreState: (id: string) => {
const targetStore = pinia._s.get(id)
return targetStore ? JSON.parse(JSON.stringify(targetStore.$state)) : null
},
getTimeTravelLog: () => {
return store._timeTravelLog || []
},
}
}
}
}
Pattern 4: Network Request State Plugin
interface AsyncState {
loading: boolean
error: string | null
lastFetch: number | null
}
declare module 'pinia' {
export interface DefineStoreOptionsBase<S> {
asyncActions?: Record<string, (...args: unknown[]) => Promise<unknown>>
}
}
export function createAsyncActionPlugin() {
return ({ store, options }: PiniaPluginContext) => {
const asyncConfig = options.asyncActions
if (!asyncConfig) return
const asyncStates = reactive<Record<string, AsyncState>>({})
Object.entries(asyncConfig).forEach(([name, action]) => {
asyncStates[name] = { loading: false, error: null, lastFetch: null }
store[name] = async (...args: unknown[]) => {
asyncStates[name].loading = true
asyncStates[name].error = null
try {
const result = await action.call(store, ...args)
asyncStates[name].lastFetch = Date.now()
return result
} catch (e) {
asyncStates[name].error = (e as Error).message
throw e
} finally {
asyncStates[name].loading = false
}
}
})
store.$asyncStates = readonly(asyncStates)
}
}
// Usage
export const useApiStore = defineStore('api', {
state: () => ({ users: [] }),
asyncActions: {
async fetchUsers(this: any) {
const res = await fetch('/api/users')
this.users = await res.json()
},
},
})
// In component
const apiStore = useApiStore()
apiStore.$asyncStates.fetchUsers.loading // boolean
Pattern 5: Production-Grade Plugin Composition Framework
interface PluginConfig {
id: string
plugin: PiniaPlugin
order?: number
enabled?: boolean
}
export class PiniaPluginManager {
private plugins: PluginConfig[] = []
register(config: PluginConfig): this {
this.plugins.push(config)
return this
}
install(pinia: Pinia, app: App): void {
const enabledPlugins = this.plugins
.filter((p) => p.enabled !== false)
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
enabledPlugins.forEach(({ id, plugin }) => {
try {
pinia.use(plugin)
if (import.meta.env.DEV) {
console.log(`[PiniaPluginManager] Registered: ${id}`)
}
} catch (e) {
console.error(`[PiniaPluginManager] Failed to register ${id}:`, e)
}
})
}
}
// Usage
const manager = new PiniaPluginManager()
.register({ id: 'logger', plugin: createLoggerPlugin(), order: 10 })
.register({ id: 'persistence', plugin: createPersistencePlugin(), order: 20 })
.register({ id: 'devtools', plugin: createDevToolsPlugin(), order: 30, enabled: import.meta.env.DEV })
.register({ id: 'async', plugin: createAsyncActionPlugin(), order: 40 })
manager.install(pinia, app)
Pitfall Guide: 5 Common Traps
1. ❌ Directly modifying store.$state in plugins → ✅ Use store.$patch() to trigger reactive updates
2. ❌ Forgetting to handle storage exceptions → ✅ Persistence plugins must try-catch to prevent crashes from full storage or private mode
3. ❌ Reactive side effects in plugins with no cleanup → ✅ Use onScopeDispose or manually clean up on component unmount
4. ❌ Multiple plugins injecting properties with the same name → ✅ Use namespace prefixes like $persistence_status to avoid conflicts
5. ❌ Using localStorage in SSR → ✅ Detect the environment and skip browser APIs or use cookies during SSR
Error Troubleshooting: 10 Common Errors
| Error Message | Cause | Solution |
|---|---|---|
Cannot read property 'getItem' of undefined |
No localStorage in SSR | Check typeof window before using storage |
getActivePinia was called but no pinia was active |
Plugin registered after Store creation | Ensure pinia.use() is called before Store creation |
Maximum call stack exceeded |
Modifying state in $subscribe callback causing loop | Check if the callback triggers itself |
Store "$asyncStates" is not defined |
Type declaration not effective | Ensure declare module 'pinia' is in a d.ts file |
Cannot set property which has only getter |
Trying to modify a readonly property injected by plugin | Use Object.defineProperty with writable flag |
Plugin did not return a function |
Plugin factory function not called | Use pinia.use(createPlugin()) not pinia.use(createPlugin) |
hydration mismatch |
Persisted data inconsistent with SSR initial state | Skip hydration during SSR or use consistent data source |
$onAction callback not triggered |
Listener registered after action execution | Register $onAction immediately after Store creation |
Storage quota exceeded |
localStorage space insufficient | Limit persisted fields or use IndexedDB |
Property '$asyncStates' does not exist |
TypeScript type extension not effective | Check if d.ts file is included in tsconfig |
Advanced Tips
1. Plugin Hot Updates: In dev mode, leverage Vite HMR for hot replacement of plugin logic without page refresh.
2. Plugin Config Validation: Use zod or TypeBox for runtime validation of plugin options, catching configuration errors early in development.
3. Plugin Performance Monitoring: Record execution time in $subscribe and $onAction, warning when thresholds are exceeded.
4. Inter-Plugin Communication: Access registered plugin list via pinia._p for coordination and dependency management between plugins.
5. Conditional Plugin Registration: Dynamically register plugins based on routes, user permissions, etc., reducing unnecessary runtime overhead.
Comparison: Pinia Plugins vs Vuex Plugins vs Manual State Management
| Feature | Pinia Plugins | Vuex Plugins | Manual State Mgmt |
|---|---|---|---|
| Type Safety | ✅ Full TS support | ❌ Requires extra config | ✅ Self-managed |
| Plugin API Simplicity | ✅ Single function signature | ❌ Complex subscribe/action | - |
| DevTools Integration | ✅ Native support | ✅ Native support | ❌ Manual implementation |
| Store Extension | ✅ Inject state/action | ❌ Only mutation/action | ✅ Free extension |
| Persistence | ✅ One-click plugin | ❌ Requires third-party lib | ✅ Manual implementation |
| SSR Compatibility | ✅ Built-in support | ⚠️ Needs attention | ✅ Self-handled |
| Learning Curve | Low | Medium | Low |
| Testability | ✅ Easy to mock | ❌ Hard to isolate | ✅ Fully controllable |
Recommended Online Tools
- JSON Formatter — When debugging Pinia persistence data, format and inspect Store snapshots in localStorage
- Hash Encoding Tool — Generate deterministic hashes for persistence keys to avoid cross-app key conflicts
- cURL to Code Converter — Convert API requests into Pinia Store action code
Conclusion and Outlook
The Pinia plugin system is a severely underestimated capability in Vue 3 state management. Through 5 core patterns — basic plugin structure, persistence, DevTools integration, network request state, and plugin composition framework — you can extract common logic from Stores into reusable plugins, keeping business Stores lean. In 2026, Pinia 3 is expected to introduce an official persistence plugin and more complete plugin lifecycle APIs, making the plugin ecosystem even more mature.
Further Reading
- Pinia Official Plugin Docs — Official Pinia plugin documentation
- pinia-plugin-persistedstate — Community persistence plugin reference implementation
- Vue 3 Design Patterns — Vue 3 design patterns and best practices
- TypeScript Module Augmentation — How to extend Pinia types
- Pinia 3 Roadmap — Future Pinia version planning discussions
Try these browser-local tools — no sign-up required →