Pinia State Management: The Vue Store That Replaced Vuex
A complete guide to Pinia for Vue 3 — store patterns, TypeScript integration, composable-style stores, persistence, and when to reach for Pinia vs local state.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Vuex served Vue well, but it always had a friction problem. The four-concept API (state, getters, mutations, actions) felt heavyweight for most real-world use cases. Mutations existed to enable devtools tracking, but they were verbose and added a layer of indirection that confused developers coming from other frameworks. When Pinia landed as the official state management recommendation for Vue 3, it felt like the community finally exhaled.
I have been using Pinia in production since it was still in early releases, and I have opinions about how to use it well. Here is what I have learned.
Why Pinia Over Vuex
The pitch is simple. Pinia gives you Vue 3's Composition API ergonomics in a store. No mutations — actions can mutate state directly. Full TypeScript inference without plugins or workarounds. Devtools integration that actually works. Store composition that does not feel like a workaround.
The API surface is smaller and more intuitive. Here is a complete Pinia store:
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const loading = ref(false)
const isAuthenticated = computed(() => user.value !== null)
const displayName = computed(() => user.value?.name ?? 'Guest')
async function fetchUser() {
loading.value = true
try {
const response = await $fetch('/api/user/me')
user.value = response
} finally {
loading.value = false
}
}
function logout() {
user.value = null
}
return { user, loading, isAuthenticated, displayName, fetchUser, logout }
})
That is the entire store. No separate mutations. Actions modify state directly. The computed properties work exactly like Vue composables because this is just a Vue composable with a registration mechanism on top.
Options Style vs Composable Style
Pinia supports two store definition styles. The options style looks familiar to Vuex users:
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
The composable style uses ref, computed, and functions directly, as shown in the first example. I default to the composable style on all new projects. It has better TypeScript inference, it is more familiar if you are already using the Composition API, and it composes better with external composables.
Use the options style only if you have team members who are more comfortable with the Vuex mental model and the transition friction is a concern.
TypeScript Integration
Pinia's TypeScript support is one of its strongest selling points. The composable style store infers types automatically from your ref and computed declarations. You get full autocomplete and type checking when using the store in components:
<script setup lang="ts">
import { useUserStore } from '~/stores/user'
const userStore = useUserStore()
// userStore.user is typed as User | null
// userStore.isAuthenticated is typed as boolean
// userStore.fetchUser is typed as () => Promise<void>
</script>
No manual type declarations needed. The store is fully typed from the implementation.
For stores with complex state shapes, define interfaces explicitly:
interface CartItem {
productId: string
quantity: number
price: number
}
interface CartState {
items: CartItem[]
couponCode: string | null
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(item: CartItem) {
const existing = items.value.find(i => i.productId === item.productId)
if (existing) {
existing.quantity += item.quantity
} else {
items.value.push(item)
}
}
return { items, couponCode, total, addItem }
})
Composing Stores
One of Pinia's design wins is how stores compose with each other. You can use one store inside another:
export const useOrderStore = defineStore('order', () => {
const cartStore = useCartStore()
const userStore = useUserStore()
async function submitOrder() {
if (!userStore.isAuthenticated) {
throw new Error('Must be logged in to submit order')
}
const order = {
userId: userStore.user!.id,
items: cartStore.items,
total: cartStore.total,
}
await $fetch('/api/orders', { method: 'POST', body: order })
cartStore.items = []
}
return { submitOrder }
})
In Vuex, accessing one store from another required namespaced module access that felt clunky. In Pinia, it is just a function call.
State Persistence
For state that should survive page refreshes — authentication tokens, user preferences, shopping cart contents — use the pinia-plugin-persistedstate package:
// plugins/pinia.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
Then enable persistence per store:
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
return { token, refreshToken }
}, {
persist: {
key: 'auth',
storage: persistedState.localStorage,
// Only persist these specific fields
pick: ['token', 'refreshToken'],
},
})
Be thoughtful about what you persist. Persisting large objects or derived state creates synchronization bugs. Persist only the minimal state needed to restore sessions.
When Not to Use Pinia
This is the conversation I have with developers who reach for a store for everything. Not all state belongs in a store.
Local component state — whether a dropdown is open, which tab is active, the current value of a text input — belongs in ref in the component. If that state is never shared with other components and does not need to survive navigation, keep it local.
Server state — data fetched from an API — often belongs in a data fetching layer (TanStack Query's Vue wrapper, or Nuxt's useAsyncData) rather than a Pinia store. Stores do not have built-in cache invalidation, stale-while-revalidate behavior, or request deduplication. If your store is mostly just mirroring API responses, a proper data fetching library handles that better.
Pinia is the right tool for genuinely shared application state: authentication, user preferences, shopping cart, multi-step form state that spans multiple routes, real-time connection state.
Testing Pinia Stores
Stores are easy to test because they are just functions. Use Vitest with createPinia from the testing utilities:
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'
describe('CartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('adds items to cart', () => {
const cart = useCartStore()
cart.addItem({ productId: 'p1', quantity: 1, price: 29.99 })
expect(cart.items).toHaveLength(1)
expect(cart.total).toBe(29.99)
})
it('increments quantity for duplicate items', () => {
const cart = useCartStore()
cart.addItem({ productId: 'p1', quantity: 1, price: 29.99 })
cart.addItem({ productId: 'p1', quantity: 2, price: 29.99 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(3)
})
})
No mocking needed for the store itself. Mock the API calls inside actions using vi.fn() or MSW. This gives you fast, isolated tests that cover the logic without touching the network.
Devtools Integration
Pinia ships with Vue Devtools integration out of the box. Every store is inspectable in the devtools panel — you can see current state, trigger actions manually, and time-travel through state changes. This integration works without any configuration, which is a welcome improvement over setting up Vuex devtools.
Migrating From Vuex
If you are on a Vue 3 project still using Vuex, the migration to Pinia is straightforward but takes time. Do not do a full rewrite. Instead, convert stores one at a time as you work on related features. Pinia and Vuex can coexist in the same application during migration.
Map the Vuex concepts: state becomes refs, getters become computed, mutations become direct state assignments inside actions, actions stay actions. The biggest conceptual shift is that mutations disappear — actions can now directly modify state.
Pinia is the Vue store I have been waiting for since I started building Vue applications. It respects developer time, TypeScript, and the Composition API mental model. If you are building anything in Vue 3, this is the state management solution to reach for.
Designing a Nuxt or Vue 3 application and want help thinking through your state management architecture? Let's talk: calendly.com/jamesrossjr.