In Pinia, what does a store plugin receive in its context, and how would you use it to audit-log every action of every store?
answer
- one argument, four keys
- pinia, app, store, options
- options.actions even for setup stores
- runs in the store's own scope
- $onAction with after and onError
basics
~20 sA Pinia plugin receives one context object with pinia, app, store and options; an audit plugin calls store.$onAction on each store and logs the action name, arguments and outcome from the after and onError hooks.
solid answer
~40 sPinia calls a plugin with a context of four keys: `pinia` (the root instance), `app` (the Vue app it was installed in), `store` (the store being created) and `options` (what was passed to `defineStore()`, with `options.actions` filled in even for setup stores). It runs once per store, when the store is first created, inside that store's effect scope. For audit logging I register a plugin that calls `store.$onAction()`: the callback gets the action `name` and `args` before the action runs, and I record the outcome with `after` for success and `onError` for failures, tagging each entry with `store.$id`. Plugins given to `pinia.use()` before `app.use(pinia)` are queued until installation, but a store that already exists when a plugin is added never gets it.
code
ts · 9 linesimport { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { auditPlugin } from './stores/audit-plugin'
const pinia = createPinia()
pinia.use(auditPlugin)
createApp(App).use(pinia).mount('#app')go deeper
Know the four context keys a Pinia plugin receives: pinia, app, store and options, and that the plugin runs for each store.
Explain when plugins run: once per store at creation, queued until app.use(pinia), never retroactively, and inside the store's effect scope.
Design the audit plugin with care: allow-list stores or actions, avoid logging sensitive arguments, keep the pre-action callback cheap and batch sends.
Decide whether auditing belongs in the store layer at all or at the API boundary, and what the team gains or loses by making it implicit for every store.
## The plugin context Pinia calls each plugin with a single **context** object, typed `PiniaPluginContext`: | Key | What it is | Typical use | |---|---|---| | `pinia` | the root instance from `createPinia()` | reach `pinia.state`, share data between stores | | `app` | the Vue app the pinia was installed into with `app.use(pinia)` | read app-level configuration | | `store` | the store being created, already reactive | add properties, call `$subscribe()`, `$onAction()`, `$patch()` | | `options` | the options given to `defineStore()` | read custom options, list `options.actions` | `options` is the options object of an **option store**, or the third argument of a **setup store**. In both cases Pinia guarantees an `options.actions` object: for an option store it is the `actions` you wrote, and for a setup store Pinia fills it with the functions the setup function returned. A plugin can therefore see every action name whichever syntax the store uses. ## When a plugin runs 1. `pinia.use(plugin)` records the plugin. If `app.use(pinia)` has not run yet, Pinia **queues** it and activates it during installation, so registering before or after `app.use(pinia)` both work. 2. A store is created lazily, on the first `useXxxStore()` call for its id on that pinia. 3. At the end of creation, Pinia runs every active plugin in registration order, **inside the store's own effect scope**, and merges what each returns onto the store. 4. Later `useXxxStore()` calls return the cached store; plugins do not run again. Two consequences follow. A plugin added after a store already exists never reaches that store. And a store created on a pinia that was never installed in an app gets no plugins, which is a common surprise in unit tests (`createTestingPinia()` from `@pinia/testing` takes a `plugins` list for this). ## An audit-logging plugin Audit logging must observe every action of every store, which is what `store.$onAction()` provides. Its full behaviour belongs to action hooks; what the plugin needs is that the callback runs **before** each action with the action's `name` and `args`, plus two hooks for the outcome. ```ts import type { PiniaPluginContext } from 'pinia' import { sendAuditEntry } from '@/services/audit' export function auditPlugin({ store }: PiniaPluginContext) { if (store.$id === 'session') return store.$onAction(({ name, args, after, onError }) => { const startedAt = performance.now() after(() => { sendAuditEntry({ store: store.$id, action: name, args, ok: true, ms: performance.now() - startedAt }) }) onError((error) => { sendAuditEntry({ store: store.$id, action: name, args, ok: false, error: String(error) }) }) }) } ``` What each piece relies on: - `store.$id` tags each entry and lets the plugin skip a store whose arguments carry credentials. - `after` receives the action's return value, and for an async action it waits until the promise resolves, so durations cover the real work. - `onError` runs when the action throws or its promise rejects. Pinia still rethrows the error to the caller; the plugin only observes it. - The subscription is created inside the store's effect scope, so it lives as long as the store, not as long as the component that happened to call `useXxxStore()` first. ## Using the other keys - `options.actions` lets a plugin decide per action, for example logging only actions whose names appear in a custom option. - `pinia` gives access to `pinia.state`, the root state of every store, when an entry needs context from elsewhere. - `app` is useful when a plugin needs the application's configuration; it is the same app `pinia` was installed in. ## Design notes for audit logging - Log arguments deliberately: action arguments may include personal data, so allow-list stores or actions rather than logging everything blindly. - Keep the callback cheap: it runs synchronously before every action, so batch network sends rather than awaiting them. - Register the plugin once, where the pinia is created, so every store created afterwards is covered. - Decide what an entry means for nested calls: an action that calls another through the store (`this.save()` in an option store) produces two entries, while a setup store calling its own local function directly produces one, because only the copy on the store is wrapped. - Remember the plugin runs wherever stores are created, including during server rendering, so the sending function must work there or be skipped. ## Common mistakes - Expecting Vuex-style `state`, `getters`, `commit` and `dispatch` in the context. - Adding the plugin after stores were already used and wondering why they are not logged. - Assuming `options.actions` is empty for setup stores.
- An audit entry's onError callback returns false; does the failing action still reject for its caller?Yes. In Pinia 4.0.3 the action wrapper calls every `onError` callback and then rethrows the error, or returns a rejected promise for an async action, regardless of what the callbacks return. An audit plugin observes failures; it cannot swallow them.
- How can the audit plugin log only the actions a store author opts into?Read a custom option from the context: the store passes something like `audit: ['checkout']` in its options (the third argument for a setup store), the plugin checks `options.audit` and compares it with the `name` in `$onAction`. Type the option by augmenting `DefineStoreOptionsBase` so TypeScript accepts it.
- Why does the $onAction subscription made in a plugin survive the unmount of the component that first used the store?Pinia runs plugins inside the store's own effect scope, so a subscription created there is tied to the store, not to the component whose setup triggered creation. It is removed when the store is disposed with `$dispose()`, not when that component unmounts.
saying these in an interview costs you the question
- The plugin context holds state, getters, commit and dispatch as in Vuex
- A plugin added later is applied to stores that already exist
- Calling pinia.use() before app.use(pinia) silently drops the plugin
- An $onAction made in a plugin stops when the first component unmounts
- options.actions is always empty for setup stores
- Returning false from onError stops the error reaching the caller