| |

Vue.js 52 🟢 Pinia Actions, Getters, and Direct Reactive State Access

⏱️ Estimated read: 12 min | |

A Pinia store is more than a container for data. It has three distinct parts that work together: state (the data itself), getters (computed values derived from state), and actions (methods that change state or perform side effects). Understanding how these three interact—and how to access them from components without breaking reactivity—is the difference between a store that works and a store that silently stops updating the UI.

The most common mistake in Pinia is destructuring state or getters directly from the store object. Because a Pinia store is a reactive proxy, destructuring it extracts plain values, severing the reactive connection . The component renders once with the initial value and never updates again. The fix is storeToRefs(), which converts state and getters into refs that stay connected to the store. Actions do not need this treatment—they are plain functions bound to the store, and destructuring them directly is safe .

This chapter covers getters in both Option and Setup syntax, actions including async actions and cross-store calls, and the correct patterns for accessing state, getters, and actions from components. It also covers the subtle cases: parameterized getters that lose caching, accessing other stores inside getters and actions, and the SSR constraint that all useStore() calls must happen before any await .

Key point: Getters are computed values that cache based on their reactive dependencies; actions are methods that can mutate state directly and can be async; state and getters must be destructured with storeToRefs() to preserve reactivity, while actions can be destructured directly.


Why getters, actions, and access patterns matter

The caching problem. A getter is not just a function that reads state—it is a computed under the hood. It caches its result and only recomputes when its dependencies change . This is what makes it different from a plain method. If you compute a filtered list in a method and call it from the template, it runs on every render. If you compute it in a getter, it runs only when the underlying state changes. Understanding this caching is essential for performance, and understanding when it is lost—parameterized getters that return functions—is essential for avoiding silent performance regressions .

The mutation problem. Pinia removed mutations. Actions can mutate state directly, and there is no distinction between synchronous and asynchronous state changes . This simplifies the mental model compared to Vuex, but it also means actions are where all side effects live. Keeping mutations inside actions (rather than mutating state from components) preserves the ability to track changes, debug with devtools, and reason about when state changes happen.

The reactivity problem. Direct destructuring of a store breaks reactivity because the store is a reactive proxy. const { count } = store extracts the current numeric value, not a live reference . The storeToRefs() function exists specifically to solve this, creating refs for state and getters while skipping actions (which are functions and do not need reactivity) . Using toRefs() from Vue works too but is less efficient because it processes actions as well .

The cross-store problem. Stores can use other stores. A getter can call useOtherStore() to combine data from multiple sources . An action can call another store’s action. This composition is how large applications avoid a single monolithic store. The constraint is that all useStore() calls must happen synchronously at the top of the setup or action—calling them after an await in SSR breaks hydration .


a. Getters in both syntaxes

Getters are defined differently depending on whether you use an Option Store or a Setup Store. In an Option Store, they live under the getters key. In a Setup Store, they are computed() values returned from the setup function.

// Option Store
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  }
})
// Setup Store
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  return { count, doubleCount }
})

In an Option Store, the getter receives state as its first argument. To access another getter, you must use this with an explicit return type in TypeScript, or this without annotation in JavaScript . In a Setup Store, getters are just computed calls that reference other refs directly—there is no this and no special argument.

Parameterized getters return a function. This is useful for looking up an item by ID, but it loses the caching benefit because the returned function is recreated on every access . If you need caching within a parameterized getter, you can compute the filtered set first and return a function that searches it:

getters: {
  getActiveUserById(state) {
    const activeUsers = state.users.filter(u => u.active)
    return (userId) => activeUsers.find(u => u.id === userId)
  }
}

Getters can access other stores by calling useOtherStore() inside the getter function . This is how a store derives data from multiple sources without duplicating state.


b. Actions and async operations

Actions are methods that can mutate state. Unlike Vuex, there is no separate mutation concept—actions do the work directly.

// Option Store
actions: {
  increment() {
    this.count++
  },
  async fetchUser(id) {
    this.user = await api.getUser(id)
  }
}
// Setup Store
function increment() {
  count.value++
}
async function fetchUser(id) {
  user.value = await api.getUser(id)
}

In an Option Store, this refers to the store instance, so this.count++ works. In a Setup Store, you mutate the ref directly with .value. Actions can be async and can call other actions or other stores.

When calling another store from an action, call useStore() at the top of the action, before any await. In SSR, this is required because Pinia needs to know all stores that will be used during rendering .

async fetchUserPreferences() {
  const auth = useAuthStore() // before await
  if (auth.isAuthenticated) {
    this.preferences = await fetchPreferences()
  }
}

Actions can return values. An async action returns a Promise that resolves to whatever the action returns. This is useful for callers that need to know when the action completes or what it produced.


c. Direct state access and storeToRefs

Once a store is instantiated with useStore(), its state, getters, and actions are all accessible directly on the store object.

const store = useCounterStore()
store.count        // state, reactive
store.doubleCount  // getter, reactive
store.increment()  // action

This direct access is the simplest pattern and works perfectly in templates and in script logic where you are not destructuring. The problem arises when you want to destructure.

const { count, doubleCount, increment } = store

count and doubleCount are now plain values. If the store changes, they do not update. increment still works because it is a function that closes over the store, but the state is frozen at the moment of destructuring .

The correct pattern is storeToRefs():

import { storeToRefs } from 'pinia'

const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store)
const { increment } = store

storeToRefs() creates refs for every state property and getter, skipping actions . count and doubleCount are now refs that stay connected to the store. increment is destructured directly because actions are bound to the store and do not need refs.

You can also use toRefs() from Vue, but storeToRefs() is more efficient because it ignores actions rather than converting them to refs unnecessarily .


Complete Example Session

The following session builds a task store with getters, actions, and demonstrates the correct access patterns in a component.

// ============================================
// PART 1: OPTION STORE WITH GETTERS AND ACTIONS
// ============================================
import { defineStore } from 'pinia'

export const useTaskStore = defineStore('tasks', {
  state: () => ({
    tasks: [],
    filter: 'all'
  }),
  getters: {
    completed: (state) => state.tasks.filter(t => t.done),
    pending: (state) => state.tasks.filter(t => !t.done),
    filtered(state) {
      if (state.filter === 'completed') return state.tasks.filter(t => t.done)
      if (state.filter === 'pending') return state.tasks.filter(t => !t.done)
      return state.tasks
    },
    taskById: (state) => {
      return (id) => state.tasks.find(t => t.id === id)
    }
  },
  actions: {
    add(title) {
      this.tasks.push({ id: Date.now(), title, done: false })
    },
    toggle(id) {
      const task = this.tasks.find(t => t.id === id)
      if (task) task.done = !task.done
    },
    async fetchFromServer() {
      const response = await fetch('/api/tasks')
      this.tasks = await response.json()
    }
  }
})

// ============================================
// PART 2: SETUP STORE EQUIVALENT
// ============================================
import { ref, computed } from 'vue'

export const useTaskStoreSetup = defineStore('tasks-setup', () => {
  const tasks = ref([])
  const filter = ref('all')

  const completed = computed(() => tasks.value.filter(t => t.done))
  const pending = computed(() => tasks.value.filter(t => !t.done))
  const filtered = computed(() => {
    if (filter.value === 'completed') return tasks.value.filter(t => t.done)
    if (filter.value === 'pending') return tasks.value.filter(t => !t.done)
    return tasks.value
  })
  const taskById = computed(() => {
    return (id) => tasks.value.find(t => t.id === id)
  })

  function add(title) {
    tasks.value.push({ id: Date.now(), title, done: false })
  }
  function toggle(id) {
    const task = tasks.value.find(t => t.id === id)
    if (task) task.done = !task.done
  }
  async function fetchFromServer() {
    const response = await fetch('/api/tasks')
    tasks.value = await response.json()
  }

  return { tasks, filter, completed, pending, filtered, taskById, add, toggle, fetchFromServer }
})

// ============================================
// PART 3: COMPONENT WITH CORRECT ACCESS
// ============================================
// <script setup>
import { storeToRefs } from 'pinia'

const store = useTaskStore()

// State and getters: use storeToRefs
const { tasks, filtered, completed, pending } = storeToRefs(store)

// Actions: destructure directly
const { add, toggle, fetchFromServer } = store

// Direct access also works
// store.tasks, store.filtered, store.add()
// </script>

// ============================================
// PART 4: PARAMETERIZED GETTER USAGE
// ============================================
const task = store.taskById(123)
// Caching is lost: the function is recreated on each access

// ============================================
// PART 5: ACCESSING ANOTHER STORE IN A GETTER
// ============================================
import { useUserStore } from './user'

export const useDashboardStore = defineStore('dashboard', {
  getters: {
    userTasks(state) {
      const user = useUserStore()
      return state.tasks.filter(t => t.userId === user.id)
    }
  }
})

// ============================================
// PART 6: ASYNC ACTION WITH SSR-SAFE STORE CALL
// ============================================
export const useOrderStore = defineStore('orders', {
  actions: {
    async placeOrder(items) {
      const user = useUserStore() // before await
      const result = await api.createOrder({ items, userId: user.id })
      this.lastOrder = result
      return result
    }
  }
})

The ten parts cover the Option Store definition with getters and actions, the Setup Store equivalent, the component access pattern with storeToRefs, parameterized getter usage, cross-store access in a getter, and the SSR-safe pattern of calling useStore() before await. What the session demonstrates is that getters and actions are the two halves of a store’s behavior—getters derive, actions mutate—and that the access pattern in components determines whether reactivity survives.


Quick Reference

Getters vs Actions

AspectGettersActions
PurposeDerive dataMutate state, side effects
CachingYes (computed)No
AsyncNoYes
Option syntaxgetters: {}actions: {}
Setup syntaxcomputed()function()
Access via thisYes (Option)Yes (Option)

Access Patterns

OperationCodeReactive?
Direct accessstore.countYes
Destructure stateconst { count } = storeNo
Destructure with refsconst { count } = storeToRefs(store)Yes
Destructure actionconst { increment } = storeN/A (function)

Parameterized Getters

PatternCaching
getById: (state) => (id) => ...Lost
getById(state) { const filtered = ...; return (id) => ... }Preserved within filtered set

SSR Rule

SituationCorrect
Calling another storeBefore any await
Calling after awaitBreaks SSR hydration

Best Practices

✅ Do This:

const { count } = storeToRefs(store)                       // ✅ state refs
const { increment } = store                               // ✅ action destructure
const active = state.users.filter(u => u.active); return (id) => active.find(...) // ✅ cache param getter
const user = useUserStore(); await api.call()             // ✅ store before await

❌ Don’t Do This:

const { count } = store                                   // ❌ breaks reactivity
const { increment } = storeToRefs(store)                  // ❌ action is undefined
await api.call(); const user = useUserStore()             // ❌ breaks SSR

Common Pitfalls

PitfallWhy It HappensFix
UI never updatesState destructured without refsUse storeToRefs()
Action undefined after destructureIncluded in storeToRefsDestructure actions from store directly
Parameterized getter slowFunction recreated each accessPre-filter, then return function
SSR hydration mismatchuseStore() after awaitMove store calls before await
Getter not updatingMissing reactive dependencyEnsure getter reads reactive state

Real-World Examples

1. Basic getter

getters: { double: (state) => state.count * 2 }

2. Getter with another getter (Option)

doublePlusOne() { return this.double + 1 }

3. Getter with argument

userById: (state) => (id) => state.users.find(u => u.id === id)

4. Async action

async load() { this.data = await api.fetch() }

5. Cross-store action

async place() { const cart = useCartStore(); await api.order(cart.items) }

6. storeToRefs for state

const { items, total } = storeToRefs(cartStore)

7. Action destructure

const { addItem, clear } = cartStore

8. Direct access in template

{{ store.count }} <button @click="store.increment">

9. Getter accessing another store

combined(state) { const other = useOtherStore(); return state.x + other.y }

10. SSR-safe action

async init() { const auth = useAuthStore(); await auth.check() }

Visual

Getter Caching

┌──────────────────────────────────────────────┐
│  state.count changes                         │
│         │                                    │
│         ▼                                    │
│  getter doubleCount recomputes               │
│         │                                    │
│         ▼                                    │
│  template using doubleCount updates          │
│                                              │
│  state.count does NOT change                 │
│         │                                    │
│         ▼                                    │
│  doubleCount returns cached value            │
└──────────────────────────────────────────────┘

Destructuring Reactivity

┌──────────────────────────────────────────────┐
│  const store = reactive({ count: 0 })        │
│         │                                    │
│         ├── const { count } = store          │
│         │   count = 0 (plain value)          │
│         │   store.count = 1                  │
│         │   count is still 0 ❌              │
│         │                                    │
│         └── const { count } = storeToRefs    │
│             count = ref(0)                   │
│             store.count = 1                  │
│             count.value = 1 ✅               │
└──────────────────────────────────────────────┘

Store Access Decision

┌──────────────────────────────────────────────┐
│  Need to destructure?                        │
│  ├─ NO → store.count, store.increment()      │
│  └─ YES → What are you extracting?           │
│          ├─ State/Getter → storeToRefs()     │
│          └─ Action → direct destructure      │
└──────────────────────────────────────────────┘

Action with Cross-Store Call

┌──────────────────────────────────────────────┐
│  async placeOrder() {                        │
│    const user = useUserStore()  ← before await│
│    const result = await api.create(...)      │
│    this.order = result                       │
│  }                                           │
│                                              │
│  SSR: useUserStore() must be called          │
│  synchronously before any await              │
└──────────────────────────────────────────────┘

Summary

ItemValue
GettersComputed values, cached
ActionsMethods, can be async, mutate state
Option gettersgetters: {}, receive state
Setup getterscomputed()
Option actionsactions: {}, use this
Setup actionsplain functions
State accessstore.count
Destructure statestoreToRefs() required
Destructure actionsDirect, no refs needed
Parameterized gettersLose caching
Cross-storeuseStore() before await

Key takeaways:

  • Getters are computed values. They cache based on reactive dependencies and only recompute when those dependencies change .
  • Actions mutate state directly. There are no mutations in Pinia. Actions can be async and can call other stores.
  • Direct access works; destructuring breaks reactivity. store.count is reactive. const { count } = store is not .
  • storeToRefs() solves destructuring. It creates refs for state and getters, skipping actions .
  • Actions destructure directly. They are functions bound to the store and do not need refs.
  • Parameterized getters lose caching. Returning a function from a getter recreates it on every access. Pre-filter for partial caching .
  • Cross-store calls must happen before await. In SSR, calling useStore() after an async operation breaks hydration .

Remember: Pinia’s three concepts—state, getters, actions—map to data, computed, and methods. Getters cache; actions mutate. Access them directly on the store for simplicity, but when you destructure, use storeToRefs() for state and getters and direct destructuring for actions. Parameterized getters sacrifice caching for flexibility, so structure them carefully. And in SSR, always resolve stores before the first await. These rules are not style preferences—they determine whether your UI updates and whether your server-rendered HTML matches the client.


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!