Skip to content
Deep divePinia

Why storeToRefs drops your actions

const { items, total, add } = storeToRefs(cart) looks reasonable, and add is undefined. The reason is one if in Pinia's source, and the fix is one extra line.

vuejs.school team9 Apr 20265 min readPinia 3 · Vue 3.5
cartitemstotaladd()refsRef<items>Ref<total>

You have a Pinia store with some state, a getter and an action. In a component you destructure all three through storeToRefs(), because you read that destructuring a store loses reactivity. The state shows up. The getter shows up. Clicking the button throws, because the action is undefined.

storeToRefs() skips actions on purpose: it returns a ref for each piece of state and each getter, and nothing else. The fix is to take actions straight off the store, which is safe because Pinia binds them to it. The examples use Pinia 3 (3.0.4 is the current release) and Vue 3.5.

The symptom: the action is undefined

Here is a small cart, written as a setup store: refs become state, computed values become getters, and functions become actions.

src/stores/cart.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export interface CartItem {
  name: string
  price: number
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))

  function add(item: CartItem) {
    items.value.push(item)
  }

  return { items, total, add }
})

And here is the component that breaks:

src/components/CartSummary.vueBreaks
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'

const { items, total, add } = storeToRefs(useCartStore())
</script>

<template>
  <p>{{ items.length }} items, {{ total }} in total</p>
  <button type="button" @click="add({ name: 'Sticker', price: 6 })">Add a sticker</button>
</template>

The paragraph renders. The button throws a TypeError when you click it, because add is undefined. If you run vue-tsc, it catches the problem earlier: add does not exist on the type that storeToRefs() returns. That type error is the first clue. The function is not missing by accident; it was never part of the result.

What storeToRefs returns

storeToRefs() is short. This is a simplified copy of the function in Pinia 3.0.4, with the TypeScript types removed:

storeToRefs, simplified
import { computed, isReactive, isRef, toRaw, toRef } from 'vue'

export function storeToRefs(store) {
  const rawStore = toRaw(store)
  const refs = {}
  for (const key in rawStore) {
    const value = rawStore[key]
    if (value.effect) {
      // a getter: a computed that reads and writes through the store
      refs[key] = computed({
        get: () => store[key],
        set: (next) => { store[key] = next },
      })
    } else if (isRef(value) || isReactive(value)) {
      // state: a ref linked to store[key]
      refs[key] = toRef(store, key)
    }
    // anything else is skipped, including every action
  }
  return refs
}

The loop walks over the raw store (the plain object under the reactive wrapper) and sorts each property into one of three cases:

  • Getters. A computed ref carries an effect property and a plain ref does not. Vue has no public way to ask "is this a computed?", so Pinia checks for effect. Each getter becomes a computed ref that reads and writes through the store.
  • State. A ref or a reactive object is state, and toRef() turns it into a ref that reads and writes store[key]. State that a plugin adds is a ref too, so it comes along.
  • Everything else is skipped.

An action lands in the last case. It is a plain function: it has no effect, and it is neither a ref nor reactive, so the loop never assigns it. The comment above the real function says this outright: methods and non reactive properties are completely ignored.

That is a design decision, not an oversight. A function has no value that changes over time, so there is nothing to keep in sync, and wrapping it in a ref would only make you write add.value().

The TypeScript types follow the same split. Pinia's StoreToRefs type is built from three parts: a ref for each state property, a ref for each property a plugin adds to the state, and a computed ref for each getter. Actions are not one of the parts, which is why vue-tsc flagged add before the browser did.

Why destructuring the store breaks reactivity

Since actions are fine as they are, you might destructure everything straight from the store and skip storeToRefs(). The action then works, but the state and getters lose their reactivity. The store is a reactive() object, and destructuring a reactive object reads each property once:

const cart = useCartStore()
const { total } = cart // reads cart.total once: a number

cart.add({ name: 'Mug', price: 12 })
console.log(total, cart.total) // 0 12

Vue tracks reactivity through property access on the proxy. cart.total in a template is a property read that Vue records, so the template updates when the total changes. const { total } = cart is also a property read, but it happens once, in setup, and copies the current number into a local constant. Nothing links that constant to the store afterwards. Pinia's own docs make the same point with a destructured name that never changes.

Arrays and objects hide the bug for a while. const { items } = cart gives you the store's reactive array, so push from the action still shows up. The day the store replaces the array, with items.value = [] in a "clear cart" action, your local items keeps pointing at the old one.

Props can make this confusing. Since Vue 3.5, const { title } = defineProps<{ title: string }>() stays reactive, which suggests destructuring should simply work. It works there because the compiler rewrites every use of title into props.title. That rewrite only happens for defineProps. A store is an ordinary object at runtime, and the compiler does nothing special with it.

The fix: data through storeToRefs, actions from the store

Split the destructuring by what each piece is. Reactive data goes through storeToRefs(). Actions come straight off the store, as in Pinia's docs.

src/components/CartSummary.vueWorks
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'

const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { add } = cart
</script>

<template>
  <p>{{ items.length }} items, {{ total }} in total</p>
  <button type="button" @click="add({ name: 'Sticker', price: 6 })">Add a sticker</button>
</template>

items and total are now refs linked to the store. In the template they unwrap on their own; in the script you write total.value. Writing works too: items.value = [] writes to the store's state, because a ref made by toRef() passes the assignment through.

Taking add off the store is safe because Pinia binds actions. Every action is wrapped when the store is created, and the wrapper calls your function with the store as this whenever it is called on its own. So add() behaves exactly like cart.add(), including in option stores whose actions use this.items. The wrapper is also what lets $onAction subscribers see the call, so destructured actions still show up there.

The simplest option: use the store as it is

Destructuring is optional. The store is already reactive, so you can use it directly and skip both lines:

src/components/CartSummary.vue
<script setup lang="ts">
import { useCartStore } from '../stores/cart'

const cart = useCartStore()
</script>

<template>
  <p>{{ cart.items.length }} items, {{ cart.total }} in total</p>
  <button type="button" @click="cart.add({ name: 'Sticker', price: 6 })">Add a sticker</button>
</template>

Every read goes through the proxy, so every read is tracked. No .value, no question about which helper a property needs, and the cart. prefix tells anyone reading the template where the data lives. Start here. Reach for storeToRefs() when a component uses the same few properties many times and the prefix gets in the way, or when you need to hand a ref to a composable.

The same rule in your own composables

The pattern is not specific to Pinia. A composable that returns a reactive() object has the same destructuring problem, and the convention in Vue is to return an object of refs and plain functions instead:

src/composables/useCounter.ts
import { reactive, toRefs } from 'vue'

export function useCounter() {
  const state = reactive({ count: 0, step: 1 })

  function increment() {
    state.count += state.step
  }

  return { ...toRefs(state), increment }
}

toRefs() gives one ref per property of state, so const { count, increment } = useCounter() keeps count live. The function goes in as it is, because, like a Pinia action, it has nothing to keep in sync.

Vue's toRefs() on a store would keep state and getters live too, but it makes a ref for every property: actions included, along with Pinia's own properties such as $patch and $id. storeToRefs() exists to skip those.

The rule matters most when store data flows into a composable. A composable that accepts a ref or a getter stays live:

src/composables/usePrice.ts
import { computed, toValue, type MaybeRefOrGetter } from 'vue'

const formatter = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' })

export function usePrice(amount: MaybeRefOrGetter<number>) {
  return computed(() => formatter.format(toValue(amount)))
}

usePrice(total), with total from storeToRefs(), updates with the cart. So does usePrice(() => cart.total). usePrice(cart.total) passes a number, read once: the same bug as const { total } = cart, in a new place. This time vue-tsc stays quiet, because a plain number is a valid MaybeRefOrGetter<number>.

Check it stuck3 questions from the storeToRefs quiz.
Practice now
Was this helpful?

Keep reading