In Pinia, what does a $subscribe callback receive for each kind of write, and how do you build a preferences audit log from it?
answer
- mutation plus live state
- three MutationType values
- payload only for object patches
- no old value given
- snapshot and diff yourself
basics
~20 sA $subscribe callback gets a mutation (type 'direct', 'patch object' or 'patch function', the storeId, and a payload only for object patches) plus the live state. An audit log keeps its own snapshot and diffs, because no old value is passed.
solid answer
~40 s`store.$subscribe((mutation, state) => …)` returns an unsubscribe function. `mutation.type` is a `MutationType`: `'direct'` for assignments like `prefs.theme = 'dark'`, `'patch object'` for `$patch({ … })` (then `mutation.payload` holds that object) and `'patch function'` for `$patch(fn)`, which an option store's `$reset()` and assigning `$state` also produce. `mutation.storeId` names the store; `mutation.events` is development-only debugging data. The second argument is the store's live state, not a before/after pair, so an audit log keeps a deep copy of the previous state, diffs it against the new one on every call and records the changed fields. With the default flush, several direct writes in one tick arrive as one call, so one entry may cover several fields. Pinia 4 ignores subscribing the same callback twice.
code
ts · 18 linesimport { usePreferencesStore } from './preferences'
type Entry = { at: string; type: string; changes: string[] }
export const auditLog: Entry[] = []
const prefs = usePreferencesStore()
let previous = JSON.parse(JSON.stringify(prefs.$state))
export const stopAudit = prefs.$subscribe((mutation, state) => {
const now = state as Record<string, unknown>
const changes = Object.keys(now)
.filter((k) => JSON.stringify(now[k]) !== JSON.stringify(previous[k]))
.map((k) => `${k}: ${JSON.stringify(previous[k])} -> ${JSON.stringify(now[k])}`)
if (changes.length) {
auditLog.push({ at: new Date().toISOString(), type: mutation.type, changes })
}
previous = JSON.parse(JSON.stringify(state))
})go deeper
Know that $subscribe takes a callback with a mutation and the state, and returns a function that removes it.
Name the three mutation types, say when payload exists, and explain that the state argument is the live object with no old value.
Build the log on your own snapshot and diff, account for direct writes batched per tick, keep events out of production logic, and record intent through named actions.
Weigh a subscription-based audit trail against recording intent at action boundaries, deciding which is the system of record and what each can miss.
## The callback signature `store.$subscribe(callback, options?)` registers a callback that runs when the store's state changes and **returns a function that removes it**. The callback receives two arguments: a **mutation** object describing the write, and the store's **state**. | `mutation.type` (`MutationType`) | Produced by | `mutation.payload` | |---|---|---| | `'direct'` (`MutationType.direct`) | `prefs.theme = 'dark'`, `prefs.mutedChannels.push(x)`, `v-model` | absent | | `'patch object'` (`MutationType.patchObject`) | `prefs.$patch({ fontSize: 16 })` | the object passed to `$patch` | | `'patch function'` (`MutationType.patchFunction`) | `prefs.$patch((s) => …)`, and also `$reset()` in an option store and `prefs.$state = …` | absent | The other fields: - `mutation.storeId` is the store's id, the same as `store.$id`; - `mutation.events` carries Vue's reactivity debugger events. The types mark it **development only**; do not build production logic on it. The second argument is `pinia.state.value[storeId]`, the **live, reactive** state object. It is the same object on every call and it already contains the new values. ## What the callback does not tell you There is **no old value**. `$subscribe` is built on a deep `watch()` of the store's state object; the object itself never changes identity, so there is no previous snapshot to hand over. And only object patches carry a payload: a direct write, a function patch, a `$reset()` and a `$state` assignment all arrive without one. So the mutation tells you *how* the state was written, not *what* changed. Two more gaps matter for an audit log: - with the default flush, **several direct writes in the same tick produce one call**, so one call can cover several fields; - `$reset()` is not a separate type; it looks like any other function patch. ## Building the audit log The reliable pattern is to keep your own snapshot and diff against it: 1. When you subscribe, take a **deep copy** of the current state as `previous`. For JSON-shaped preferences a JSON round trip is enough. 2. In the callback, compare `previous` with `state` field by field and collect each changed path with its old and new value. 3. Push one log entry per call: timestamp, `mutation.type`, `mutation.storeId` and the list of changes. Add `mutation.payload` when present, as a record of what the caller asked for. 4. Replace `previous` with a fresh deep copy of `state`. 5. Keep the function `$subscribe` returned, and call it when the log should stop. Never store `state` itself in the log: it is the live object, so every stored entry would show the latest values. If the log must say *why* a change happened ("user pressed Reset"), the mutation cannot tell you. Route such changes through named actions and record the action name from an action hook, alongside the diff. ## Pinia 4: one callback, one subscription Since **Pinia 4.0.0**, passing the same callback function to `$subscribe` a second time is ignored: the call returns a no-op remover, and a development build logs diagnostic `PINIA_R1007`. Before 4.0 a second watcher was created for the same callback. If a component re-runs subscription code, give it a fresh function, or call the earlier remover first. ## Why not a plain watch()? A deep `watch()` on the store's state would also fire on every change. What `$subscribe` adds is the **kind of write** and, for object patches, the **payload**. It also treats each `$patch` as one change: subscribers are called once, synchronously, when the patch is applied, instead of reacting to each field the patch touched. ## Mistakes that corrupt an audit log - **Logging `state` by reference.** Every stored entry then shows today's values; copy or diff instead. - **Trusting `payload` as the change list.** It exists only for object patches and records what the caller passed, which may include fields whose values did not actually change. - **Using `mutation.events` in production.** It is debugging data, typed as development only. - **Assuming one entry per field.** Batched direct writes and patches both cover several fields in one call; the diff must list them all. - **Forgetting to stop.** A subscription created for a feature that can be switched off must be removed with the function `$subscribe` returned. A careful candidate also mentions where the log lives: an audit trail kept only in memory disappears on reload, so entries are usually flushed to a server or to storage by separate code.
- Why must a Pinia audit log copy state instead of storing the state argument?The second argument is the store's live reactive state object, the same object on every call. Storing it keeps a reference, not a record, so every past entry would display the current values. A deep copy per entry, or a list of changed fields with their values, freezes what the state was at that moment.
- How can a Pinia audit log tell a reset apart from other edits?Not from the mutation: `$reset()` in an option store arrives as `'patch function'` with no payload, like any function patch. Record the intent where it happens instead, for example by performing the reset through a named action and logging that action's name next to the diff the subscription produces.
saying these in an interview costs you the question
- The $subscribe callback receives the old and new state as two arguments.
- mutation.payload is present for every write, including direct writes.
- mutation.events is a production-safe source of old and new values.
- Storing the state argument in the log preserves a history of values.
- Every direct write always gets its own callback, whatever the flush.