Vue.js: Pinia State Management
Last updated: 2026-10-20
Pinia is the state management library officially recommended for Vue 3 — it replaces Vuex. Pinia offers a more concise API, full TypeScript support, modular hot reloading, and native DevTools integration. Vue now recommends Pinia by default, and Vuex is no longer maintained.
Pinia lets you manage state shared across components (user info, shopping cart, global settings) with more structure than provide/inject and 50% less ceremony than Vuex.
1. What You'll Learn
- Why Pinia replaces Vuex (5 advantages)
- The two defineStore styles (Options / Setup)
- The three core concepts: state / getter / action
- Modular stores in Pinia (multiple stores)
- Persistence (pinia-plugin-persistedstate)
- DevTools integration
- 5 real-world scenarios
2. The Nightmare of a Shopping Cart "Inconsistent Across 5 Components"
(1) Pain Point: 5 Components Each Keep Their Own Cart Data
Alice's e-commerce admin has 5 components that need cart data:
// ❌ The broken version: 5 components, 5 copies of the data
// CartIcon.vue
const cartCount = ref(0)
// ProductCard.vue
const localCart = ref([])
// CartPage.vue
const cart = ref({ items: [], total: 0 })
// CheckoutPage.vue
const myCart = ref([])
// Header.vue
const cartItems = ref([])
Product manager Charlie:
"Alice, I add a product in ProductCard and the cart icon doesn't update! The icon says '0' but the page says '1 item'. Five components, five copies of the cart data — they are out of sync!"
(2) The Pinia Solution: One Store Shared by 5 Components
// stores/cart.js
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
total: 0
}),
getters: {
itemCount: (state) => state.items.length,
totalPrice: (state) => state.items.reduce((sum, i) => sum + i.price, 0)
},
actions: {
addItem(product) {
this.items.push(product)
this.total += product.price
},
removeItem(id) {
this.items = this.items.filter(i => i.id !== id)
}
}
})
<!-- CartIcon.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
// Automatically reactive: when cart.itemCount changes, the icon updates at once
</script>
<template>
<span>🛒 {{ cart.itemCount }}</span>
</template>
<!-- ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>
<template>
<button @click="cart.addItem(product)">Add to Cart</button>
</template>
One store, 5 components synced in real time.
(3) Benefits
After adopting Pinia:
- Data consistency: one store, 5 components in sync
- Lines of code: 5 refs → 1 store (-80%)
- DevTools: time-travel debugging (inspect every step)
- TypeScript: full type inference
- Persistence: automatic localStorage saving
3. Pinia vs Vuex: 5 Advantages
| Aspect | Vuex 4 (Vue 3) | Pinia (Vue 3) |
|---|---|---|
| Concise API | ⭐⭐⭐ Verbose (mutations / actions) | ⭐⭐⭐⭐⭐ Concise (state / getters / actions) |
| TypeScript | ⭐⭐⭐ Extra setup required | ⭐⭐⭐⭐⭐ Native support |
| Composition API | ⭐⭐ Unfriendly | ⭐⭐⭐⭐⭐ First-class |
| DevTools | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ Improved |
| Bundle size | ~10KB | ~1KB |
Vue officially recommends Pinia, and Vuex 4 is in maintenance mode (no new features are being added).
4. The Two defineStore Styles
(1) Options Style (Vuex-Like)
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state: data
state: () => ({
count: 0,
name: 'Counter'
}),
// getters: derived values (like computed)
getters: {
doubleCount: (state) => state.count * 2,
isZero: (state) => state.count === 0
},
// actions: methods (like methods)
actions: {
increment() {
this.count++
},
async fetchData() {
const res = await fetch('/api/count')
this.count = await res.json()
}
}
})
(2) Setup Style (Recommended, Composition API)
// stores/auth.js
import { defineStore } from 'pinia'
const { ref, computed } = Vue
export const useAuthStore = defineStore('auth', () => {
// 1. state (via ref)
const user = ref(null)
const token = ref(localStorage.getItem('token') || '')
// 2. getters (via computed)
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => user.value?.name || 'Guest')
// 3. actions (plain functions)
function login(credentials) {
// API call...
user.value = { name: 'Alice' }
token.value = 'xxx'
}
function logout() {
user.value = null
token.value = ''
}
return { user, token, isLoggedIn, userName, login, logout }
})
(3) Options vs Setup
| Aspect | Options style | Setup style |
|---|---|---|
| Style | state: () => ({}) |
const x = ref() |
| getters | (state) => ... |
computed() |
| actions | function() { this.x } |
Plain functions |
| TypeScript | Manual | Inferred automatically |
| Composability | Weaker | Strong (other composables can be used) |
| Recommendation | For Vuex veterans | For new projects |
▶ Example: Simulating defineStore's Options Style with reactive
▶ Try it YourselfThere is no Pinia CDN dependency available in the browser, so this example hand-writes a store with
reactive+provide/inject: state is just object fields, getters aregetaccessors, and actions are plain methods.
Output:
The root panel shows "count: 0 | doubleCount: 0 | isZero: true" and the child panel shows "count: 0 | doubleCount: 0".
Clicking the root "+1" twice moves both panels to count: 2 and doubleCount: 4, and isZero flips to false.
Clicking "Child +1" pushes the root to count: 3 and doubleCount: 6; clicking reset zeroes both at once and isZero returns to true.
5. The 5 Core APIs
(1) state: The Data
// Options style
state: () => ({
count: 0,
user: null,
items: []
})
// Setup style
const count = ref(0)
const user = ref(null)
const items = ref([])
(2) getters: Derived Values
// Options style
getters: {
// a simple getter
doubleCount: (state) => state.count * 2,
// access another getter (through this)
ratio(state) {
return this.doubleCount / 100
},
// return a function (a parameterized getter)
getItemById: (state) => (id) => {
return state.items.find(i => i.id === id)
}
}
// Setup style
const doubleCount = computed(() => count.value * 2)
const getItemById = (id) => items.value.find(i => i.id === id)
(3) actions: Methods
// Options style
actions: {
// synchronous
increment() {
this.count++
},
// asynchronous
async fetchData() {
const res = await fetch('/api/data')
this.data = await res.json()
},
// call other actions
async loginAndFetch(credentials) {
await this.login(credentials)
await this.fetchUser()
}
}
// Setup style
function increment() {
count.value++
}
async function fetchData() {
const res = await fetch('/api/data')
data.value = await res.json()
}
(4) Using a Store in a Component
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
const cart = useCartStore()
// 1. Access state directly (reactive)
console.log(cart.items)
// 2. Destructure with storeToRefs (stays reactive)
const { items, total } = storeToRefs(cart)
// 3. Call an action
cart.addItem(product)
// 4. Watch state changes
watch(() => cart.items, (newItems) => {
console.log('Cart changed:', newItems)
})
</script>
(5) 5 Things to Watch Out For
// ⚠️ Note 1: destructuring state loses reactivity
const { items } = cart // ❌ items is a plain value
const { items } = storeToRefs(cart) // ✅ items is a ref
// ⚠️ Note 2: mutate state through an action
cart.items.push(...) // ❌ not recommended (direct mutation)
cart.addItem(...) // ✅ use an action
// ⚠️ Note 3: this inside an action points at the store
actions: {
increment() {
this.count++ // ✅ this = store
}
}
// ⚠️ Note 4: getter caching
getters: {
doubleCount() {
console.log('recomputed') // only logs when a dependency changes
return this.count * 2
}
}
// ⚠️ Note 5: outside setup, pass the pinia instance to useStore
import { getActivePinia } from 'pinia'
const cart = useCartStore(getActivePinia()) // inside a .js file
6. Pinia Persistence
(1) Installing the Plugin
npm install pinia-plugin-persistedstate
(2) Configuring main.js
(3) 5 Persistence Configurations
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
const items = ref([])
return { items }
}, {
// 1. Default: the localStorage key is 'cart'
persist: true,
// 2. Custom key
persist: {
key: 'my-cart',
storage: localStorage
},
// 3. Persist only part of the state
persist: {
paths: ['items'] // only items is saved, total is not
},
// 4. sessionStorage (cleared when the browser closes)
persist: {
storage: sessionStorage
},
// 5. Custom serialization (encryption, etc.)
persist: {
serializer: {
serialize: (value) => btoa(JSON.stringify(value)),
deserialize: (value) => JSON.parse(atob(value))
}
}
})
▶ Example: Hand-Rolling a Persistent Store with watch + localStorage
▶ Try it YourselfThis is exactly what
pinia-plugin-persistedstatedoes: read the cache on startup to restore state, then use a deepwatchto write state back tolocalStoragewhenever it changes.
Output:
First open shows "Theme: light | Cart: 0 item(s)", the localStorage line reads (empty), and the child badge is 0.
"Toggle theme" switches the theme to dark; clicking "Add an item" twice makes the cart 2 items and the child badge 2.
The localStorage line updates live to {"theme":"dark","items":[{"id":1766...},{"id":1766...}]}.
Reload the page: the theme is still dark and the cart still holds 2 items (restored from the cache); "Clear cart" empties the items array.
7. Putting It Together: One Store Shared by Many Components
▶ Example: Pinia-Style Global State (Simulated with provide/inject on the CDN)
▶ Expand runnable example (109 lines)
Output:
Three bordered panels render at once.
Panels 1 and 2 show the same count/double numbers because they inject the same singleton store — clicking +1 in either panel updates both.
Panel 3 tracks its own cart store: the first click on "Add 1 iPhone" shows items: 1, total: $999, the next $1998, and "Clear" resets it to 0 / $0.
Panel 1 count: 0 | double: 0 Panel 2 count: 0 | double: 0
Click +1 in panel 2 -> both panels: count: 1 | double: 2 (one shared store)
Click Reset in panel 1 -> both panels: count: 0 | double: 0
Panel 3 items: 0, total: $0
Add iPhone x2 -> items: 2, total: $1998
Clear -> items: 0, total: $0
pinia package (which requires Vite) for DevTools, TypeScript, and modular store splitting.
(1) 📊 The 5 Core APIs at a Glance (Pinia)
| API | Purpose | Example |
|---|---|---|
| state | Reactive state | const count = ref(0) |
| getters | Cached derived values | computed(() => count * 2) |
| actions | Methods (may be async) | function increment() { count++ } |
| storeToRefs | Destructure while staying reactive | const { count } = storeToRefs(cart) |
| Persistence | localStorage sync | pinia-plugin-persistedstate |
Output:
A five-row reference table mapping each core API to its purpose and a one-line example — the fastest way to recall the surface area of a store.
Renders one table: 5 rows x 3 columns (API | Purpose | Example).
(2) 📊 5 Common Mistakes at a Glance
| Mistake | Symptom | Fix |
|---|---|---|
| Destructuring state | Data never updates | Use storeToRefs |
| Mutating state directly | Warning | Move it into an action |
| Forgetting await on an async action | Result not ready | await cart.fetchData() |
| pinia not registered | useStore is not a function |
app.use(pinia) in main.js |
| A field is not persisted | Wrong key | Check the paths config |
Output:
A five-row troubleshooting table covering the Pinia errors you are most likely to hit, from lost reactivity to missing plugin registration, each with its one-line fix.
Renders one table: 5 rows x 3 columns (Mistake | Symptom | Fix).
(3) 📊 5 Real-World Scenarios
| Scenario | Store | Key fields |
|---|---|---|
| Authentication | useAuthStore | user, token, isLoggedIn |
| Shopping cart | useCartStore | items, total, itemCount |
| Theme | useThemeStore | theme, locale, density |
| Notifications | useNotificationStore | notifications, unread |
| Data | useDataStore | list, loading, error |
Output:
A five-row mapping table from business domain to store name and its key fields — a ready-made skeleton for splitting an app's global state into modular stores.
Renders one table: 5 rows x 3 columns (Scenario | Store | Key fields).
❓ FAQ
src/stores/. Pinia supports tree-shaking automatically.import { createPinia } from 'pinia' and create a fresh instance per request. Nuxt 3 has Pinia integration built in.📖 Summary
- Pinia is the state management library officially recommended for Vue 3, replacing Vuex
- 5 advantages: concise / TypeScript / Composition API / DevTools / small bundle
- Two defineStore styles: Options (familiar from Vuex) / Setup (recommended)
- 5 core APIs: state / getters / actions / storeToRefs / persistence
- Modularity: one file per store
- Persistence: pinia-plugin-persistedstate
- Pinia vs composables: global state vs component logic
📝 Exercises
-
Basic (difficulty ⭐) Build a simple counter store:
- count state
- doubleCount getter
- increment / decrement / reset actions
- Share it across 3 components
-
Intermediate (difficulty ⭐⭐) Build a shopping cart store:
- items array
- itemCount / totalPrice getters
- addItem / removeItem / clearCart actions
- Persist to localStorage
- Test it in 5 components (ProductCard / CartIcon / CartPage / Checkout / Header)
-
Challenge (difficulty ⭐⭐⭐) Build a complete e-commerce admin store system:
- 5 stores: auth / cart / theme / notification / product
- Full state + getters + actions for each store
- Persistence (auth/cart/theme)
- Setup style with TypeScript
- storeToRefs destructuring
- Cross-store calls (cart asks auth whether the user is logged in)
- Unit tests (with Vitest)