skip to content

In Pinia, what does store.$onAction() let you observe, and how do its after and onError hooks behave for an async placeOrder action?

level: middleimportance: should knowfreq 40%

answer

  1. listener runs before the action
  2. name, store, args
  3. after gets the resolved value
  4. onError observes, does not swallow
  5. second argument true detaches

basics

~20 s

$onAction registers a listener that runs before every action of the store, with its name, store and args plus after and onError. For an async action, after receives the resolved value and onError the rejection, which still reaches the caller.

solid answer

~40 s

`order.$onAction(({ name, store, args, after, onError }) => { … })` runs the listener synchronously before each action call. Inside it, `after(cb)` registers a callback that receives the action's return value or, for a promise, its resolved value; `onError(cb)` registers one that runs if the action throws or its promise rejects. The hooks only observe: the caller still gets the result or the error. Variables declared in the listener are shared by that call's hooks, which makes timing easy (`const start = Date.now()`). The call returns a function that removes the listener. Inside a component the listener is removed on unmount unless you pass `true` as the second argument: a plain boolean, unlike `$subscribe`'s `{ detached: true }` option object. Typical uses are timing checkout calls, logging failures and reporting errors in one place.

code

ts · 15 lines
ts
import { useOrderStore } from '@/stores/order'

export function trackCheckout(report: (line: string) => void) {
  const order = useOrderStore()
  return order.$onAction(({ name, args, after, onError }) => {
    if (name !== 'placeOrder') return
    const start = Date.now()
    after((orderId) => {
      report(`placeOrder ok in ${Date.now() - start}ms -> ${orderId}`)
    })
    onError((error) => {
      report(`placeOrder failed after ${Date.now() - start}ms: ${String(error)} (${args.length} args)`)
    })
  }, true) // detached: survives component unmounts
}

go deeper

for a junior

Know that $onAction runs a callback whenever an action of the store is called, and returns a function to remove it.

for a middle

Explain the context fields, when after and onError run for sync and async actions, and the boolean detached argument.

for a senior

Use one detached listener for timing and error reporting, and know that setup-store internal calls bypass the wrapper.

for a principal

Decide which cross-cutting concerns hang off action hooks versus explicit code in actions, keeping observability out of business logic.

## What $onAction observes `store.$onAction(listener)` watches **action calls** on one store, not state changes. Every time any action of that store is called, Pinia runs the listener **before** the action body, synchronously, with a context object: | Field | Meaning | |---|---| | `name` | the action's name, e.g. `'placeOrder'` | | `store` | the store instance the action was called on | | `args` | the array of arguments passed | | `after(cb)` | register `cb` to run after the action returns or resolves | | `onError(cb)` | register `cb` to run if the action throws or rejects | The listener can register several `after` and `onError` callbacks, and several listeners can observe the same store. ## after and onError for an async action For `await order.placeOrder()`: 1. The listener runs first; it can record `const start = Date.now()` and register hooks. 2. The action body runs and returns a promise. 3. If the promise **resolves**, every `after` callback receives the **resolved value** (the new order id), not the promise. 4. If it **rejects**, every `onError` callback receives the error. 5. Either way, the caller receives the same outcome: the resolved value, or the rejection. For a synchronous action, `after` runs right after it returns, with the return value, and `onError` runs if it throws; the error is then rethrown to the caller. The important property: **hooks observe, they do not intercept**. `onError` cannot turn a failed checkout into a success, and returning a value from `after` changes nothing for the caller. ## Lifetime and detaching `$onAction` returns a function that removes the listener. When it is called inside a component's `setup`, the listener is also removed automatically when the component unmounts. To keep it: - pass `true` as the second argument: `order.$onAction(listener, true)`; - or register it outside any component, for example at application start-up. The second argument is a **boolean**. The state subscription API, `$subscribe`, takes an options object with `detached: true` instead; the two are easy to mix up. ## Typical uses - **Timing**: measure how long `placeOrder` takes, per call, using a variable shared by the listener and its hooks. - **Error reporting**: one listener with `onError` forwards every failed action of the store, with its `name` and `args`, to the app's error reporting. - **Audit and analytics**: record that a checkout was attempted, succeeded or failed, without touching the action's code. ## Setup stores: calls between local functions Pinia observes an action by wrapping the function the store exposes. In a setup store, if `placeOrder` calls a local function `validateCart()` directly, that call goes to the original function, not to the wrapped one on the store, so `$onAction` does **not** see it. Calls made through the store (`order.validateCart()`), and option-store calls through `this`, are wrapped and observed. For the rare case that needs internal calls observed, Pinia passes setup stores an `action` helper, `defineStore('order', ({ action }) => …)`, whose wrapped functions are tracked even when called inside the store; its own docs describe it as rarely needed. ## Slips to avoid - Expecting the listener to run after the action: only `after` does. - Using `onError` to swallow failures: the caller still sees them. - Passing `{ detached: true }` to `$onAction` out of `$subscribe` habit: the parameter is typed as a boolean. - Assuming `$onAction` fires on state changes: it fires on action calls only. ## A sync action, step by step For a synchronous `order.addItem(item)` with one listener registered: 1. the listener runs with `name: 'addItem'` and `args: [item]`, and registers an `after` callback; 2. `addItem` runs and returns `undefined`; 3. the `after` callback runs immediately with `undefined`, before the call returns to the component. If `addItem` threw instead, the `onError` callbacks would run and the same error would then be thrown to the component. The timing difference between sync and async actions is only *when* `after` runs; the contract is the same. ## $onAction versus $subscribe The two hooks answer different questions. `$onAction` tells you **which operation** was called, with what arguments, and how it ended, which suits timing, logging and error reporting. `$subscribe` tells you **that state changed** and how it was written, which suits persistence and audit of data. A checkout often uses both: `$onAction` to report failed `placeOrder` calls, `$subscribe` to persist the cart.

  • In a Pinia setup store, why does $onAction miss a call that placeOrder makes to validateCart()?
    Pinia tracks actions by wrapping the functions the store exposes. Inside the setup function, `validateCart()` refers to the original local function, so the call never passes through the wrapper. Calling it through the store object, or wrapping it with the `action` helper Pinia passes to setup stores, makes the call visible to listeners.
  • Can a Pinia $onAction listener change what an action returns?
    No. The listener runs before the action, and `after` and `onError` callbacks receive the outcome without replacing it; their return values are ignored. To change behaviour, change the action, or wrap it in a Pinia plugin, which is a different mechanism from observing it.

$onAction is like a flight tracker: it registers each departure with the flight number and passengers, and asks to be told on landing or diversion, but it cannot reroute the plane.

saying these in an interview costs you the question

  • The $onAction listener runs after the action has finished.
  • An onError callback can swallow the error so the caller never sees it.
  • after receives the pending promise for an async action.
  • $onAction takes { detached: true } just like $subscribe.
  • $onAction fires whenever the store's state changes.