| |

Vue.js 53 🟢 Resetting, Subscribing to State ($subscribe), and Actions ($onAction) in Pinia

⏱️ Estimated read: 12 min | |

A Pinia store is not just a passive container. It exposes three powerful methods that let you control its lifecycle and react to its activity: $reset(), $subscribe(), and $onAction(). These methods are what turn a store from a simple data holder into an observable, resettable, and instrumentable part of your application’s architecture.

The most important distinction to internalize is that $reset() is not universal. It works out of the box for Option Stores, but Setup Stores do not have a built-in $reset()—calling it throws an error telling you the store was built using setup syntax and does not implement it . This asymmetry is deliberate, reflecting the different philosophies of the two syntaxes, but it catches many developers off guard when they migrate from one to the other.

$subscribe() and $onAction() are the observation hooks. $subscribe() fires whenever the store’s state changes, giving you the mutation type and the current state . $onAction() fires whenever an action is about to be invoked, giving you the action’s name and arguments, plus after and onError callbacks for completion and failure . Together, these two methods are the foundation for persistence plugins, audit logs, and cross-store coordination.

This chapter covers each method, the critical difference between Option and Setup Stores for $reset(), the detached option that controls subscription lifetime, and the known limitation of $onAction() with Setup Stores.

Key point: $reset() restores state to its initial values but exists only on Option Stores by default; $subscribe() observes every state change and fires a callback with the mutation and state; $onAction() observes action invocations and provides after and onError hooks. Both $subscribe() and $onAction() are automatically cleaned up when the component unmounts unless detached: true is passed.


Why these three methods exist

The reset problem. Applications need to clear state. A user logs out, and the cart must empty; a form is submitted, and the draft must clear; a test runs, and the store must return to a known baseline. Without $reset(), you would manually assign every state property back to its initial value—a maintenance burden that grows with every new field . For Option Stores, Pinia handles this automatically. For Setup Stores, you must either implement it yourself or use a plugin.

The persistence problem. State is ephemeral by default. A page refresh wipes everything. The standard solution is to subscribe to state changes and write the state to localStorage or sessionStorage on every mutation . $subscribe() provides exactly this hook: it fires after every state change, with the current state as an argument. Without it, you would need to wrap every action in persistence logic.

The instrumentation problem. Sometimes you need to know when an action is called, not just when state changes. An audit log needs the action name and arguments. A loading indicator needs to know when an action starts and finishes. $onAction() provides this: it fires before the action runs, and the after callback fires when it completes successfully or the onError callback fires when it fails . This is finer-grained than $subscribe(), which only sees the resulting state change.

The lifecycle problem. Subscriptions created inside a component are automatically cleaned up when the component unmounts. This is usually desirable—a component that is gone should not keep reacting to store changes. But sometimes you need a subscription that outlives the component: a global persistence layer, a logging plugin, or a cross-store coordination mechanism. The detached: true option exists for exactly this case .

The Setup Store limitation. $onAction() works by wrapping the store’s actions at the time the store is defined. In Setup Stores, actions are plain functions returned from the setup function. When one action calls another action defined in the same store, the call bypasses the $onAction() wrapper because it is a direct function call, not a property access on the store object . This is a known limitation that affects cross-action instrumentation in Setup Stores.


a. $reset(): Option Stores vs Setup Stores

Calling $reset() on an Option Store is straightforward. It restores the state to the values returned by the state function.

const store = useCounterStore()
store.count = 10
store.$reset()
// store.count is back to 0

The mechanism works because Pinia captures the initial state when the store is created and can replay it. For Option Stores, this is automatic .

For Setup Stores, there is no built-in $reset(). Calling it throws:

🍍: Store "xxx" is built using the setup syntax and does not implement $reset().

You must implement it yourself:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')

  function $reset() {
    count.value = 0
    name.value = 'Eduardo'
  }

  return { count, name, $reset }
})

The manual implementation is tedious and error-prone—every new state property must be added to the reset function. An alternative is a plugin that captures the initial state and injects $reset() automatically. A common pattern is to deep-clone store.$state when the store is created, then use $patch() to restore it . The pinia-plugin-setup-reset package does exactly this, and adds optional selective reset: store.$reset('name') resets only the name property .


b. $subscribe(): observing state changes

$subscribe() registers a callback that fires after every state change. The callback receives two arguments: a mutation object and the current state.

store.$subscribe((mutation, state) => {
  console.log(mutation.type)      // 'direct' | 'patch object' | 'patch function'
  console.log(mutation.storeId)   // the store's id
  console.log(mutation.payload)   // only for 'patch object'
  console.log(state)              // the current state
})

The mutation type tells you how the state changed. 'direct' means a direct assignment (store.count = 1). 'patch object' means $patch({ count: 1 }). 'patch function' means $patch((state) => { state.count = 1 }) . This distinction is useful for debugging and for filtering which changes you care about.

The most common use case is persistence. The pattern is to subscribe once, and on every change, serialize the state to storage:

store.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state))
})

On initialization, the store reads from storage:

state: () => ({
  items: JSON.parse(localStorage.getItem('cart') || '[]')
})

This combination—subscribe to write, initialize to read—is the manual persistence pattern . Libraries like pinia-plugin-persistedstate wrap this into a declarative option, but the underlying mechanism is $subscribe().

The detached option controls subscription lifetime. By default, a subscription created inside a component’s setup() is automatically removed when the component unmounts. If you want the subscription to persist beyond the component’s lifetime, pass { detached: true } as the second argument .

store.$subscribe(callback, { detached: true })

This is useful for global plugins and persistence layers that should run for the entire application lifetime.


c. $onAction(): observing action invocations

$onAction() registers a callback that fires before every action is invoked. The callback receives an object with store, name, args, after, and onError.

store.$onAction(({ name, args, after, onError }) => {
  console.log(`Action ${name} called with`, args)

  after((result) => {
    console.log(`Action ${name} completed with`, result)
  })

  onError((error) => {
    console.error(`Action ${name} failed:`, error)
  })
})

The after callback fires when the action completes. If the action is async, after receives the resolved value, not the Promise . The onError callback fires when the action throws or rejects. This gives you full lifecycle instrumentation: you know when an action starts, what it received, when it finished, and whether it succeeded.

Like $subscribe(), $onAction() returns a function that removes the subscription. And like $subscribe(), it is automatically cleaned up when the component unmounts unless detached: true is passed .

The known limitation with Setup Stores is that $onAction() does not fire when one action in the same store calls another action directly. In a Setup Store, functionA and theMotherAction are both plain functions. When theMotherAction calls functionA(data), that is a local function call, not a property access on the store. The $onAction() wrapper only intercepts calls made through the store object . Option Stores do not have this limitation because actions are properties on the store object, and this.functionA() goes through the proxy.

The workaround is to call the action through the store instance: useMyStore().functionA(data). This is awkward and defeats some of the purpose, but it is the only way to trigger $onAction() for intra-store calls in Setup Stores .


Complete Example Session

The following session builds a store with $reset(), $subscribe(), and $onAction(), demonstrating the correct patterns for both Option and Setup syntaxes.

// ============================================
// PART 1: OPTION STORE WITH BUILT-IN RESET
// ============================================
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    coupon: null
  }),
  actions: {
    add(item) {
      this.items.push(item)
    },
    applyCoupon(code) {
      this.coupon = code
    }
  }
})

// ============================================
// PART 2: SETUP STORE WITH MANUAL RESET
// ============================================
import { ref } from 'vue'

export const useUserStore = defineStore('user', () => {
  const name = ref('Alice')
  const age = ref(25)

  function $reset() {
    name.value = 'Alice'
    age.value = 25
  }

  function updateName(newName) {
    name.value = newName
  }

  return { name, age, $reset, updateName }
})

// ============================================
// PART 3: USING $RESET
// ============================================
const cart = useCartStore()
cart.add({ id: 1, name: 'Book' })
console.log(cart.items.length) // 1
cart.$reset()
console.log(cart.items.length) // 0 (built-in)

const user = useUserStore()
user.updateName('Bob')
user.$reset()
console.log(user.name) // 'Alice' (manual)

// ============================================
// PART 4: $SUBSCRIBE FOR PERSISTENCE
// ============================================
cart.$subscribe((mutation, state) => {
  console.log('Mutation type:', mutation.type)
  localStorage.setItem('cart', JSON.stringify(state))
})

cart.add({ id: 2, name: 'Pen' })
// Subscription fires, state saved to localStorage

// ============================================
// PART 5: DETACHED SUBSCRIPTION
// ============================================
cart.$subscribe((mutation, state) => {
  console.log('Detached subscription fired')
}, { detached: true })
// This subscription survives component unmount

// ============================================
// PART 6: $ONACTION WITH AFTER HOOK
// ============================================
cart.$onAction(({ name, args, after, onError }) => {
  console.log(`Action "${name}" called with`, args)

  after((result) => {
    console.log(`Action "${name}" finished`)
  })

  onError((error) => {
    console.error(`Action "${name}" failed:`, error)
  })
})

cart.add({ id: 3, name: 'Notebook' })
// Logs: Action "add" called with [{ id: 3, ... }]
// Logs: Action "add" finished

// ============================================
// PART 7: REMOVING A SUBSCRIPTION
// ============================================
const unsubscribe = cart.$subscribe((mutation, state) => {
  console.log('This will be removed')
})

unsubscribe() // subscription removed

// ============================================
// PART 8: $ONACTION WITH ERROR HANDLING
// ============================================
cart.$onAction(({ name, onError }) => {
  onError((error) => {
    console.error(`Caught error in ${name}:`, error.message)
  })
})

// If an action throws, onError fires

The eight parts cover the Option Store with built-in reset, the Setup Store with manual reset, using both reset methods, $subscribe() for persistence, detached subscriptions, $onAction() with the after hook, removing subscriptions, and $onAction() error handling. What the session demonstrates is that $reset() is syntax-dependent, while $subscribe() and $onAction() work identically in both syntaxes—with the caveat about intra-store action calls in Setup Stores.


Quick Reference

The Three Methods

MethodPurposeReturns
$reset()Restore initial statevoid
$subscribe(cb, opts?)Observe state changesUnsubscribe function
$onAction(cb, detached?)Observe action callsUnsubscribe function

$reset() Availability

Store Type$reset()
Option Store✅ Built-in
Setup Store❌ Throws error; manual or plugin required

$subscribe() Mutation Types

TypeTrigger
'direct'store.count = 1
'patch object'store.$patch({ count: 1 })
'patch function'store.$patch((s) => { s.count = 1 })

$onAction() Callback Arguments

PropertyMeaning
nameAction name
argsArguments passed to action
after(fn)Callback on success
onError(fn)Callback on failure
storeThe store instance

Detached Option

OptionEffect
detached: false (default)Auto-removed on component unmount
detached: trueSurvives component unmount

Best Practices

✅ Do This:

cart.$reset()                                              // ✅ Option Store reset
function $reset() { count.value = 0 }                      // ✅ manual Setup reset
store.$subscribe(cb, { detached: true })                   // ✅ global persistence
store.$onAction(({ after }) => after(() => log('done')))   // ✅ completion hook

❌ Don’t Do This:

setupStore.$reset()                                        // ❌ throws without manual impl
// forget to return $reset from setup store               // ❌ undefined
store.$subscribe(cb) // in a component that unmounts       // ❌ subscription dies

Common Pitfalls

PitfallWhy It HappensFix
$reset() throws on Setup StoreNot built-inImplement manually or use plugin
Subscription stops workingComponent unmountedUse { detached: true }
$onAction not firing in Setup StoreIntra-store action call bypasses proxyCall via useStore().action()
$reset not available after pluginTesting without app instanceCreate app in test setup
Persistence not workingSubscription created after state changeCreate subscription before mutations

Real-World Examples

1. Option Store reset

store.$reset()

2. Setup Store manual reset

function $reset() { count.value = 0; name.value = 'x' }

3. Persist cart to localStorage

store.$subscribe((_, state) => localStorage.setItem('cart', JSON.stringify(state)))

4. Detached persistence

store.$subscribe(cb, { detached: true })

5. Log all state mutations

store.$subscribe((mutation) => console.log(mutation.type))

6. Loading indicator via onAction

store.$onAction(({ name, after }) => {
  loading.value = true
  after(() => { loading.value = false })
})

7. Error tracking via onError

store.$onAction(({ onError }) => onError((e) => log(e)))

8. Audit log

store.$onAction(({ name, args }) => audit(`${name} called`, args))

9. Plugin-level subscription

pinia.use(({ store }) => {
  store.$subscribe(cb, { detached: true })
})

10. Remove subscription

const off = store.$subscribe(cb)
off()

Visual

Option vs Setup Reset

┌──────────────────────────────────────────────┐
│  OPTION STORE                                │
│  state: () => ({ count: 0 })                 │
│  store.count = 10                            │
│  store.$reset()                              │
│  → count = 0 ✅ (built-in)                   │
│                                              │
│  SETUP STORE                                 │
│  const count = ref(0)                        │
│  function $reset() { count.value = 0 }       │
│  return { count, $reset }                    │
│  store.$reset()                              │
│  → count = 0 ✅ (manual)                     │
│                                              │
│  Setup without $reset:                       │
│  store.$reset()                              │
│  → ❌ throws error                           │
└──────────────────────────────────────────────┘

$subscribe Flow

┌──────────────────────────────────────────────┐
│  store.count = 1                             │
│         │                                    │
│         ▼                                    │
│  Mutation recorded                           │
│  type: 'direct'                              │
│         │                                    │
│         ▼                                    │
│  $subscribe callbacks fire                   │
│  (mutation, state)                           │
│         │                                    │
│         ▼                                    │
│  Persistence logic runs                      │
│  (localStorage.setItem)                      │
└──────────────────────────────────────────────┘

$onAction Lifecycle

┌──────────────────────────────────────────────┐
│  store.add(item) called                      │
│         │                                    │
│         ▼                                    │
│  $onAction callback fires                    │
│  name: 'add'                                 │
│  args: [item]                                │
│         │                                    │
│         ▼                                    │
│  Action executes                             │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  success   error                             │
│    │         │                               │
│    ▼         ▼                               │
│  after()   onError()                         │
└──────────────────────────────────────────────┘

Detached Subscription Lifetime

┌──────────────────────────────────────────────┐
│  COMPONENT MOUNTS                            │
│  store.$subscribe(cb)                        │
│         │                                    │
│         ▼                                    │
│  COMPONENT UNMOUNTS                          │
│         │                                    │
│         ▼                                    │
│  subscription removed ❌ (not detached)      │
│                                              │
│  COMPONENT MOUNTS                            │
│  store.$subscribe(cb, { detached: true })    │
│         │                                    │
│         ▼                                    │
│  COMPONENT UNMOUNTS                          │
│         │                                    │
│         ▼                                    │
│  subscription persists ✅ (detached)         │
└──────────────────────────────────────────────┘

Summary

ItemValue
$reset()Restore initial state
Option Store resetBuilt-in
Setup Store resetManual or plugin
$subscribe()Observe state changes
Subscription cleanupOn component unmount
detached: trueSurvives unmount
$onAction()Observe action calls
after hookAction completed
onError hookAction failed
Setup Store $onAction limitationIntra-store calls not intercepted

Key takeaways:

  • $reset() is not universal. Option Stores have it built-in. Setup Stores throw an error unless you implement it manually or use a plugin .
  • $subscribe() fires on every state change. The callback receives the mutation type and the current state, making it the foundation for persistence .
  • $onAction() fires before every action. The after and onError callbacks give you completion and failure hooks .
  • Subscriptions are auto-cleaned on unmount. Pass { detached: true } to keep them alive beyond the component’s lifetime .
  • Setup Stores have an $onAction limitation. Actions calling other actions in the same store bypass the observer because they are plain function calls, not proxy property accesses .
  • Persistence uses subscribe-to-write, initialize-to-read. The state is saved on every mutation and restored from storage at store creation .

Remember: $reset(), $subscribe(), and $onAction() are the three lifecycle methods of a Pinia store. $reset() restores state but is not available on Setup Stores without manual implementation. $subscribe() observes state changes and is the mechanism behind every persistence plugin. $onAction() observes action invocations and provides completion and error hooks. Both observers are automatically cleaned up when the component unmounts unless you explicitly detach them. The Setup Store $onAction limitation is a sharp edge—if you need reliable action instrumentation across intra-store calls, Option Stores or explicit store-instance calls are the workaround.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!