Vue state management with Pinia: stores, getters, and actions
Manage global state in your Vue app with Pinia: create stores with the setup syntax, use storeToRefs, write async actions, persist state, and test your stores.
When do you need global state?
Props and emits work great for parent-child communication. Problems appear when data is used by many components that are far apart, such as the logged-in user, a shopping cart, or a theme preference. Passing that data through many layers of components (prop drilling) makes code hard to maintain.
Pinia is the official state management library for Vue and the successor to Vuex. Its advantages:
- A simple API with no mutations.
- Excellent TypeScript support.
- Integration with Vue DevTools.
- Modular stores: each feature has its own store.
Installation
npm install pinia
Register Pinia in main.js:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
Creating a store
Pinia supports two styles. The setup store style feels very similar to <script setup>:
refbecomes statecomputedbecomes a getterfunctionbecomes an action
// src/stores/cart.js
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const itemCount = computed(() =>
items.value.reduce((total, item) => total + item.qty, 0)
)
const totalPrice = computed(() =>
items.value.reduce((total, item) => total + item.price * item.qty, 0)
)
function add(product) {
const existing = items.value.find((item) => item.id === product.id)
if (existing) {
existing.qty++
return
}
items.value.push({ ...product, qty: 1 })
}
function remove(id) {
items.value = items.value.filter((item) => item.id !== id)
}
function clear() {
items.value = []
}
return { items, itemCount, totalPrice, add, remove, clear }
})
The first argument of defineStore is the store's unique ID. Every piece of state, getter, and action you want to use outside must be returned from the setup function.
The second style, the options store, resembles the Options API:
export const useThemeStore = defineStore('theme', {
state: () => ({ mode: 'light' }),
getters: {
isDark: (state) => state.mode === 'dark',
},
actions: {
toggle() {
this.mode = this.isDark ? 'light' : 'dark'
},
},
})
Both are valid. Pick one style and use it consistently within a project.
Using a store in components
<!-- CartIcon.vue -->
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { itemCount } = storeToRefs(cart)
</script>
<template>
<span>🛒 {{ itemCount }}</span>
</template>
<!-- ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
defineProps({ product: { type: Object, required: true } })
const cart = useCartStore()
</script>
<template>
<article>
<h3>{{ product.name }}</h3>
<button type="button" @click="cart.add(product)">Add to cart</button>
</article>
</template>
Both components share the same store instance. When the button in ProductCard is clicked, the number in CartIcon updates immediately, even though the two components have no parent-child relationship.
The destructuring trap
A store is a reactive object. That means destructuring it directly breaks reactivity:
const { itemCount } = useCartStore() // ❌ not reactive
const { itemCount } = storeToRefs(useCartStore()) // ✅ stays reactive
const { add } = useCartStore() // ✅ actions can be destructured directly
storeToRefs only extracts state and getters, while actions can be taken directly from the store.
Async actions
Actions can be async functions, which makes them a good fit for API calls:
// src/stores/articles.js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useArticleStore = defineStore('articles', () => {
const list = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchArticles(category = null) {
loading.value = true
error.value = null
try {
const query = category ? `?category=${encodeURIComponent(category)}` : ''
const response = await fetch(`/api/articles${query}`)
if (!response.ok) {
throw new Error(`Failed to load (${response.status})`)
}
list.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return { list, loading, error, fetchArticles }
})
A component just calls articleStore.fetchArticles('vue') and reads loading and error to display the status. The data-fetching logic lives in one place instead of being repeated on every page.
Persisting state to localStorage
To keep the cart contents when the page is refreshed, add a watch inside the setup store:
import { ref, watch } from 'vue'
const items = ref(JSON.parse(localStorage.getItem('cart') ?? '[]'))
watch(items, (value) => {
localStorage.setItem('cart', JSON.stringify(value))
}, { deep: true })
For more complete needs, community plugins such as pinia-plugin-persistedstate are available.
Using other stores
A store can use another store by calling it inside an action or the setup function:
export const useCheckoutStore = defineStore('checkout', () => {
const cart = useCartStore()
async function pay() {
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cart.items }),
})
cart.clear()
}
return { pay }
})
Avoid two stores that read each other's state inside their setup functions, as that can create a circular dependency.
Testing stores
Stores are easy to test because they don't depend on components. An example with Vitest:
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useCartStore } from '@/stores/cart'
describe('cart', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments qty when the product is already in the cart', () => {
const cart = useCartStore()
const book = { id: 1, name: 'Vue book', price: 25 }
cart.add(book)
cart.add(book)
expect(cart.itemCount).toBe(2)
expect(cart.totalPrice).toBe(50)
})
})
setActivePinia(createPinia()) ensures every test starts with a fresh store.
Don't put everything in a store
Pinia is not the place for all state. Follow these simple rules:
- Local state (form inputs, whether a dropdown is open) belongs in a
refinside the component. - Shared state used across the app (logged-in user, cart, preferences) belongs in a store.
- Reusable logic that doesn't share state is better written as a composable, a plain
useSomething()function.
Exercise
Create a useAuthStore with a user state, an isLoggedIn getter, and login and logout actions. Use the getter in a Vue Router navigation guard to protect the dashboard page, then display the user's name in the header using storeToRefs.
