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
  1. JSON Formatter — When debugging Pinia persistence data, format and inspect Store snapshots in localStorage
  2. Hash Encoding Tool — Generate deterministic hashes for persistence keys to avoid cross-app key conflicts
  3. 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

  1. Pinia Official Plugin Docs — Official Pinia plugin documentation
  2. pinia-plugin-persistedstate — Community persistence plugin reference implementation
  3. Vue 3 Design Patterns — Vue 3 design patterns and best practices
  4. TypeScript Module Augmentation — How to extend Pinia types
  5. Pinia 3 Roadmap — Future Pinia version planning discussions

Try these browser-local tools — no sign-up required →

#Pinia插件系统#Vue3状态管理#Store插件#持久化插件#2026#前端工程