Pinia: Stores, Getters, and Actions in Vue 3's Official State Library
Pinia is the official state management library for Vue 3. It replaces Vuex's mutation model with direct state writes, supports both Options and Setup store syntax, and integrates fully with Vue DevTools for time-travel debugging.
Vue 3 applications that grow beyond a small set of components eventually encounter the same pressure that drove Vuex adoption in Vue 2: shared state that multiple unrelated components need to read and update cannot be owned cleanly by any one of them. Pinia is Vue’s current official answer to this problem. It emerged from an experimental exploration of what a Vuex successor built on the Composition API could look like, and it implements the core of what the Vue team originally planned for Vuex 5. The result is a store library that removes mutations entirely, provides full TypeScript inference without wrapper overhead, and maintains DevTools integration including time-travel debugging.
Defining a Store
Every Pinia store is created with defineStore, which takes a unique string ID as its first argument and either an options object or a setup function as its second. The ID connects the store to Vue DevTools and must be unique across the application.
The Options Store syntax will feel familiar to anyone who has used Vuex or the Vue Options API. State is declared as a function returning an object, getters are placed in a getters property, and actions in an actions property.
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
The Setup Store syntax mirrors a Vue Composition API setup function. ref values become state, computed values become getters, and plain functions become actions. Everything intended to be public must be explicitly returned.
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eduardo')
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
Setup stores allow watchers and composable integrations that are not possible in the Options Store form. The trade-off is that SSR usage requires more care, and any state property not returned is invisible to Pinia’s DevTools and plugin system.
State
Accessing store state in a component is straightforward. Once the store is instantiated, its state properties are available directly on the store object. Because the store is wrapped with reactive, there is no .value unwrapping needed for state declared in an Options Store.
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<p>{{ counter.count }}</p>
</template>
State can also be mutated directly. There is no mutation function required, no commit call, and no string key to look up. Assigning to counter.count in a component or in an action updates state and notifies all dependents.
const counter = useCounterStore()
counter.count = 10
Direct destructuring of store properties breaks reactivity because the resulting variables are plain copies. To destructure while preserving reactivity, use storeToRefs, which wraps each property in a ref. Actions can be destructured directly without storeToRefs because they are plain functions.
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
const { count, name } = storeToRefs(counter)
const { increment } = counter
Getters
Getters are computed properties defined on the store. They receive state as their first argument, are cached by Vue’s reactivity system, and re-evaluate only when their dependencies change.
getters: {
doubleCount: (state) => state.count * 2,
}
When a getter needs to reference another getter on the same store, an arrow function cannot access this. A regular function must be used instead.
getters: {
doubleCount(state) {
return state.count * 2
},
doublePlusOne() {
return this.doubleCount + 1
},
}
Because getters are computed properties, they do not accept arguments directly. The pattern for a parameterised getter is to return a function. Doing so disables caching for that getter, since the returned function is called each time.
getters: {
getUserById: (state) => {
return (userId) => state.users.find((user) => user.id === userId)
},
}
A getter can also draw on another store by instantiating it inside the getter body.
import { useOtherStore } from './other-store'
getters: {
combinedData(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
}
Actions
Actions are where business logic lives. They are defined in the actions property of an Options Store or as plain functions in a Setup Store. Unlike Vuex actions, they do not receive a context object. They access state and other actions through this in an Options Store, or through the variables in scope in a Setup Store.
actions: {
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
}
Actions can be asynchronous. There is no distinction between synchronous and asynchronous actions in Pinia’s API. Any action can use await, and the caller receives a Promise.
actions: {
async registerUser(login, password) {
try {
this.userData = await api.post({ login, password })
} catch (error) {
return error
}
},
}
One store’s actions can call another store’s actions by importing and instantiating the other store inside the action body. This is the Pinia model for store composition: stores reference each other through ordinary imports rather than through a module namespace or a central registry.
import { useAuthStore } from './auth-store'
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
} else {
throw new Error('User must be authenticated')
}
},
}
Using the Store in a Component
Stores must be instantiated inside <script setup> or a setup() function. Calling useCounterStore() outside of a component setup context will fail because Pinia relies on Vue’s active instance to associate the store correctly.
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>
<template>
<button @click="store.increment()">
Count: {{ store.count }}
</button>
</template>
Actions are called as plain methods on the store instance. There is no dispatch, no string-based lookup, and no context unwrapping. Calling store.increment() invokes the action directly, with full IDE autocompletion for parameters and return types.
DevTools Integration
Pinia integrates with Vue DevTools and provides a timeline of every action call, including its arguments and duration. State changes are tracked and labelled, and the DevTools panel shows the full history of how state reached its current value. Time-travel debugging, the ability to jump back to any prior state snapshot, works for Pinia stores in the same way it did for Vuex. This is the reason the store ID passed to defineStore must be unique: DevTools uses it to identify each store in the panel and in the action timeline.
What You Can Do Now
Define a store with both a getter and an async action, use it in a component with storeToRefs, and observe the action timeline in DevTools.
// stores/users.js
import { defineStore } from 'pinia'
export const useUsersStore = defineStore('users', {
state: () => ({
list: [],
loading: false,
}),
getters: {
count: (state) => state.list.length,
getById: (state) => (id) => state.list.find((u) => u.id === id),
},
actions: {
async fetchAll() {
this.loading = true
try {
this.list = await fetch('/api/users').then((r) => r.json())
} finally {
this.loading = false
}
},
},
})
<script setup>
import { storeToRefs } from 'pinia'
import { useUsersStore } from '@/stores/users'
const store = useUsersStore()
const { list, loading, count } = storeToRefs(store)
store.fetchAll()
</script>
<template>
<p v-if="loading">Loading…</p>
<p v-else>{{ count }} users loaded</p>
<ul>
<li v-for="user in list" :key="user.id">{{ user.name }}</li>
</ul>
</template>
Open Vue DevTools after calling fetchAll and inspect the timeline entry for the action. The before and after state snapshots confirm exactly which properties changed and when. For a store composition example, add a second store that calls useUsersStore() inside one of its own actions and verify in DevTools that both stores appear separately in the panel with their own state trees.