skip to content

Actions & Action Hooks

Actions hold a store's sync and async logic and may call other stores. Interviewers probe this inside actions, circular store use, and how $onAction's after and onError hooks wrap a call.

on this pageshow

explore

questions

5

In a Pinia order store, how do you write a synchronous addItem action and an asynchronous placeOrder action, and how does a checkout component call them?

level: juniorimportance: must knowfreq 68%

answer

  1. methods of the store
  2. no commit, no dispatch
  3. this in option stores, no arrows
  4. async action returns a promise
  5. caller awaits and catches

basics

~20 s

Actions are functions: listed under actions in an option store, where they use this and so cannot be arrows, or returned from a setup store. An async action returns a promise the component awaits; failures reach the caller unless handled.

solid answer

~40 s

In an option store, `actions: { addItem(item) { this.items.push(item) }, async placeOrder() { … } }` defines both; `this` is the store, so actions must be regular functions, not arrow functions. In a setup store, every returned function is an action and uses closures instead of `this`. Components call them like methods, `order.addItem(item)` and `await order.placeOrder()`, with no `commit` or `dispatch`; arguments and return values are typed by inference. An async action can await an API call or other actions and returns a promise. If it throws or rejects, the caller receives the error, so either the action records an `error` and resolves, or the component catches it. Nothing is rolled back: state written before the failure stays written.

code

ts · 30 lines
ts
import { defineStore } from 'pinia'
import { api } from '@/api'

interface CartItem { sku: string; qty: number }

export const useOrderStore = defineStore('order', {
  state: () => ({
    items: [] as CartItem[],
    status: 'idle' as 'idle' | 'submitting' | 'placed' | 'failed',
    lastOrderId: null as string | null,
  }),
  actions: {
    addItem(item: CartItem) {
      this.items.push(item)
    },
    async placeOrder() {
      this.status = 'submitting'
      try {
        const { id } = await api.placeOrder(this.items)
        this.lastOrderId = id
        this.items = []
        this.status = 'placed'
        return id
      } catch (error) {
        this.status = 'failed'
        throw error
      }
    },
  },
})

go deeper

for a junior

Know that actions are methods under actions or functions in a setup store, can be async, and are called directly: order.placeOrder().

for a middle

Explain why option-store actions cannot be arrows, how async actions return promises, and why failed actions leave partial writes.

for a senior

Choose one error-handling contract per store, guard against double submission, and reset status in the failure path.

for a principal

Set conventions for where API calls and error state live, store actions versus components, so checkout logic is not split across layers.

## Actions are the store's methods Pinia's docs describe **actions** as the equivalent of component methods, and the place for business logic. There is no separate mutation layer: an action changes state directly, calls APIs, calls other actions and returns whatever it likes. | Store style | How an action is declared | How it reaches state | |---|---|---| | Option store | a method under `actions` | `this.items`, `this.status` | | Setup store | a function returned from the setup function | closures over refs: `items.value` | ## Option-store actions and this In an option store, Pinia calls each action with the store as `this`, fully typed, including state, getters and other actions. That is why actions are written as **regular functions**: an arrow function takes `this` from its surrounding scope and never sees the store. The docs' own example carries the comment: since we rely on `this`, we cannot use an arrow function. Pinia also binds actions for you: calling a destructured action, `const { addItem } = order; addItem(item)`, still runs with the store as `this`, so actions (unlike state) can be destructured safely. ## Async actions Unlike getters, actions can be `async`. A typical checkout action follows four steps: 1. set a status: `this.status = 'submitting'`; 2. await the API: `const { id } = await api.placeOrder(this.items)`; 3. write the result: `this.lastOrderId = id; this.items = []; this.status = 'placed'`; 4. on failure, record it: `this.status = 'failed'; this.error = message`. The action returns a **promise**. Whatever the function returns becomes the resolved value, so `placeOrder` can return the new order id to the component that needs to navigate to a confirmation page. ## Errors and partial writes - A thrown error or a rejected promise **propagates to the caller**. If no one catches it, it becomes an unhandled rejection. - Pinia does **not** roll back state when an action fails. `status` stays `'submitting'` unless the action's own `catch` resets it. - Two common designs: the action catches, stores `error`, and resolves (the component just reads `order.error`); or the action rethrows and the component shows the failure. Pick one per store and be consistent. ## Calling actions from a component - `order.addItem(item)`: a plain call, synchronous. - `await order.placeOrder()` inside an `async` handler, wrapped in `try`/`catch` when the action rethrows. - In a template: `@click="order.addItem(item)"`. - Disable the submit button while `order.status === 'submitting'`, so a double click does not place two orders. There is no `order.dispatch('placeOrder')` or `commit`: those belong to Vuex, which Pinia replaced. ## Setup-store actions In a setup store the same actions are plain functions: ```ts function addItem(item: CartItem) { items.value.push(item) } async function placeOrder() { status.value = 'submitting' const { id } = await api.placeOrder(items.value) status.value = 'placed' return id } return { items, status, addItem, placeOrder } ``` There is no `this`; the functions close over the refs. Pinia wraps each returned function as an action, so it behaves the same for callers and for action hooks. ## Slips interviewers listen for - Writing option-store actions as arrow functions. - Reaching for `dispatch` or `commit` out of Vuex habit. - Calling an async action without awaiting or catching it: a rejection then surfaces as an unhandled promise rejection. - Assuming a failed action restores the state it changed. ## Arguments, return values and typing Actions take any arguments and return anything, and Pinia infers both for callers: `order.addItem` is typed from its parameter, and `await order.placeOrder()` is typed as whatever the action returns, here the order id. In an option store, `this` inside an action is typed as the whole store, including state, getters and the other actions, so annotations are rarely needed; unlike `this`-based getters, actions normally get their return types inferred. Actions can also call each other: `this.addItem(item)` in an option store, or a plain call in a setup store. An async action can `await` another action as easily as an API call, which is how larger flows such as validate-then-submit are composed out of small actions. ## When logic belongs in an action 1. It changes the store's state, possibly after asynchronous work. 2. More than one component needs it, such as the cart page and the mini-cart both adding items. 3. It needs to be observable or testable in isolation from the UI. Logic that only formats data for one component stays in that component; logic that only derives values from state belongs in a getter.

  • Why can a Pinia action be destructured from the store while state cannot?
    Pinia wraps every action so that, when it is called without the store as `this`, it applies the store itself. `const { addItem } = order` therefore still works. State properties are plain values once destructured, copied out of the reactive store, so they stop updating; state needs `storeToRefs()` instead.
  • Should a Pinia placeOrder action catch its own errors or rethrow them?
    Either works if it is consistent. Catching and storing `error` keeps components simple: they render `order.error`. Rethrowing after recording the status lets the caller decide, for example to stay on the page instead of navigating. What must not happen is neither: a rejection nobody awaits becomes an unhandled promise rejection.

saying these in an interview costs you the question

  • Pinia actions change state through commit, like Vuex mutations.
  • Option-store actions are best written as arrow functions.
  • Actions must stay synchronous; API calls belong in components.
  • Pinia rolls back state changes when an action throws.
  • A destructured action loses its this and breaks.
open as a page

In Pinia, how does an order store's placeOrder action use the auth store's token, and why call useAuthStore() before the first await?

level: middleimportance: must knowfreq 50%

basics

~20 s

Call useAuthStore() inside the action, or at the top of a setup store, then read auth.token or call its actions. Do it before any await: after one, another request's pinia may be the active one.

open as a page

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%

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.

open as a page

In Pinia, the order store's placeOrder calls the auth store and auth's logout clears the order store; how do two setup stores use each other safely?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Both setup stores may call each other's useStore() at the top and call each other's actions. Neither may read the other's state directly in its setup body, because one of them is still unfinished then; read inside actions and computeds.

open as a page

In a Pinia setup store for checkout, what happens to a composable's watchers, lifecycle hooks and inject() calls, and what does the store expose from it?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

A composable's watchers and computeds join the store's effect scope and live until the store is disposed. Component lifecycle hooks do not follow the store, and inject() sees only app-level provides. Returned refs become state, functions actions, computeds getters.

open as a page