Using a Store in a Component

Call the store function in setup, use storeToRefs when you destructure state, and subscribe with $subscribe or $onAction.

Syntaxconst { count } = storeToRefs(useCartStore())

useCartStore() returns a reactive object. Read properties off it in the template and they update: {{ cart.count }}. Call actions as methods: cart.clear(). In the template @click="cart.clear" is the shorthand for v-on:click="cart.clear", and the two compile to the same thing.

The destructuring trap

Writing const { count } = cart copies the current number out of the reactive object. The copy never changes again, and nothing warns you. storeToRefs(store) fixes it: it returns an object of refs for the store's state, getters and plugin-added state, skipping methods. So state comes from storeToRefs, while actions can be destructured straight off the store, because they are bound functions and not reactive values.

Watching the store

store.$subscribe((mutation, state) => {}) runs after state changes. mutation.type is 'direct', 'patch object' or 'patch function', and mutation.storeId names the store. A second argument takes options: { detached: true } keeps the subscription alive after the component unmounts, and { flush: 'sync' } fires it on every change instead of batching.

store.$onAction(callback, detached?) wraps actions instead of state. The callback receives { name, store, args, after, onError }; after and onError take functions that run when the action resolves or throws.

What trips people up

Both subscriptions are bound to the component that created them and are removed on unmount unless you detach them.

Example

Example · vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()

// State and getters: through storeToRefs, so they stay connected.
const { count, formattedTotal, isEmpty } = storeToRefs(cart)
// Actions: destructured directly, they are bound to the store.
const { clear } = cart

cart.$subscribe((mutation) => {
  console.log(mutation.type, mutation.storeId)
})
</script>

<template>
  <section>
    <p>{{ count }} items, {{ formattedTotal }}</p>
    <button type="button" :disabled="isEmpty" @click="clear">
      Clear cart
    </button>
  </section>
</template>

<style scoped>
section {
  display: grid;
  gap: 0.5rem;
}
</style>

When to use it

  • Binding a store getter into a template without losing reactivity when you destructure it.
  • Saving part of a store to local storage whenever it changes.
  • Reporting every failed store action to an error tracker from one subscription.

More examples

Why the plain destructure fails

The first destructure reads the value and forgets where it came from, which is why a template bound to it never updates. The second returns a ref, so the template follows it and script code reads it through .value. Nothing throws in either case, so this is worth recognising on sight.

Example · ts
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()

// Wrong: a plain number, copied once.
const { count } = counter
counter.increment()
// Output: 0
console.log(count)

// Right: a ref that stays connected to the store.
const { count: liveCount } = storeToRefs(counter)
counter.increment()
// Output: 2
console.log(liveCount.value)

$subscribe with options

Use this to mirror a store somewhere else, such as writing it to storage or sending an analytics event. detached: true is for code that must keep running after the component that started it goes away; the cost is that you now own the cleanup, through the function $subscribe returns.

Example · ts
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()

const stop = cart.$subscribe(
  (mutation, state) => {
    // 'direct' for an assignment, 'patch object' or 'patch function' for $patch
    console.log(mutation.type, state.lines.length)
  },
  { detached: true, flush: 'sync' },
)

// Detached subscriptions outlive the component, so call stop() yourself.
stop()

$onAction for action-level hooks

The callback runs before the action body, so you can read args and register after and onError. Reach for it when you want one place that reports failed actions to an error tracker, instead of a try/catch in every action. Pass true as the second argument to keep it after the component unmounts.

Example · ts
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()

cart.$onAction(({ name, args, after, onError }) => {
  const startedAt = name

  after((result) => {
    console.log(`${startedAt} finished`, args.length, result)
  })

  onError((error) => {
    console.warn(`${startedAt} failed`, error)
  })
})

Discussion

  • Be the first to comment on this lesson.