Pinia Fundamentals
Pinia is Vue's official state management library. It replaced Vuex in 2022 after Evan You acknowledged that Pinia's design was what Vuex 5 would have been. The migration was not political -- Pinia is genuinely better: full TypeScript support without wrapper types, no mutations layer, devtools integration, and a simpler API that maps directly to the Composition API patterns you already know.
Why Pinia Over Vuex
Vuex had mutations (synchronous state changes) and actions (async operations that commit mutations). This distinction existed because Vuex's devtools needed synchronous checkpoints to track state changes. Pinia dropped mutations entirely. Actions can be sync or async. State changes happen directly.
Vuex also required a single store with modules. Pinia uses multiple independent stores. No namespacing, no nested module syntax, no rootState or rootGetters.
Creating a Store
The recommended approach uses the setup syntax, which mirrors <script setup>:
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<{ id: string; name: string; email: string } | null>(null)
const token = ref<string | null>(null)
// Getters (computed)
const isAuthenticated = computed(() => !!token.value)
const displayName = computed(() => user.value?.name ?? 'Guest')
// Actions (functions)
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error('Invalid credentials')
}
const data = await response.json()
user.value = data.user
token.value = data.token
}
function logout() {
user.value = null
token.value = null
}
return { user, token, isAuthenticated, displayName, login, logout }
})
The store ID ('auth') must be unique across all stores. It is used by devtools and for serialization.
Options Syntax (Alternative)
Pinia also supports an options syntax closer to Vuex:
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as { id: string; name: string; email: string } | null,
token: null as string | null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
displayName: (state) => state.user?.name ?? 'Guest',
},
actions: {
async login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await response.json()
this.user = data.user
this.token = data.token
},
},
})
Both syntaxes produce identical stores. The setup syntax is more flexible (you can use watchers, composables, and any Composition API feature), but the options syntax is easier to scan at a glance.
Using Stores in Components
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
<template>
<div v-if="auth.isAuthenticated">
<p>Welcome, {{ auth.displayName }}</p>
<button @click="auth.logout()">Logout</button>
</div>
<div v-else>
<LoginForm @submit="auth.login($event.email, $event.password)" />
</div>
</template>
Access state and getters directly on the store instance. Call actions as methods. The store is reactive -- changes update the template automatically.
Destructuring with storeToRefs
Destructuring a store object loses reactivity on state and getters. Use storeToRefs:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
// Reactive destructuring for state and getters
const { user, isAuthenticated, displayName } = storeToRefs(auth)
// Actions can be destructured directly (they are plain functions)
const { login, logout } = auth
</script>
storeToRefs wraps each state property and getter in a ref, preserving reactivity through destructuring.
Store Composition
Stores can use other stores. This is how you build complex state from simple pieces:
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'
export const useCartStore = defineStore('cart', () => {
const items = ref<Array<{ productId: string; name: string; price: number; quantity: number }>>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product: { productId: string; name: string; price: number }) {
const existing = items.value.find(i => i.productId === product.productId)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
async function checkout() {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
throw new Error('Must be logged in to checkout')
}
await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.token}`,
},
body: JSON.stringify({ items: items.value }),
})
items.value = []
}
return { items, total, addItem, checkout }
})
Call useAuthStore() inside the action, not at the top of the setup function. This avoids circular dependency issues when two stores reference each other.
Pinia with Nuxt
Nuxt auto-imports Pinia. No manual app.use(pinia) needed. Create stores in the stores/ directory and Nuxt auto-imports them:
// stores/auth.ts -- auto-imported in Nuxt
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
return { user }
})
<!-- No import needed in Nuxt -->
<script setup lang="ts">
const auth = useAuthStore()
</script>
Common Pitfalls
- Destructuring state without storeToRefs.
const { count } = useCounterStore()makescounta non-reactive snapshot. Always usestoreToRefsfor state and getters. - Defining store at module level.
const store = useMyStore()outside a component or setup context will fail. Always call store constructors inside setup functions, composables, or other stores' actions. - Making stores too large. A store with 20+ state properties and 30+ actions is a code smell. Split by domain:
useAuthStore,useCartStore,useUIStore. - Using stores for local component state. If state is only used by one component, a simple
refis better. Not everything needs to be global. - Circular store dependencies at the top level. If store A imports store B and store B imports store A at the top of their setup functions, you get an error. Move
useOtherStore()calls inside actions.
Key Takeaways
- Pinia replaced Vuex as Vue's official state manager. No mutations, no modules -- just stores with state, getters, and actions.
- The setup syntax lets you use any Composition API feature. The options syntax is an alternative.
- Use
storeToRefswhen destructuring state and getters. Actions can be destructured directly. - Stores can call other stores inside actions. Keep cross-store calls inside functions to avoid circular dependency issues.
- Nuxt auto-imports Pinia and stores in the
stores/directory.