skip to content

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?

level: middleimportance: must knowfreq 46%

answer

  1. one argument, four keys
  2. pinia, app, store, options
  3. options.actions even for setup stores
  4. runs in the store's own scope
  5. $onAction with after and onError

basics

~20 s

A 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 s

Pinia 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 lines
ts
import { 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

for a junior

Know the four context keys a Pinia plugin receives: pinia, app, store and options, and that the plugin runs for each store.

for a middle

Explain when plugins run: once per store at creation, queued until app.use(pinia), never retroactively, and inside the store's effect scope.

for a senior

Design the audit plugin with care: allow-list stores or actions, avoid logging sensitive arguments, keep the pre-action callback cheap and batch sends.

for a principal

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