Web Development · State Management
Pinia in 2026: Vue State Management After Vuex
Vuex was Vue's state management library for years. Pinia replaced it as the official recommendation and changed how Vue apps manage shared state. Here is how Pinia works and where it fits in a Vue 3 application.
Anurag Verma
7 min read
Sponsored
Vuex 4 was a reasonable state management library with one problem: it was designed for Vue 2 and retrofitted to Vue 3. The mutations/actions split made sense when Vue 2’s reactivity system required synchronous and asynchronous mutations to be handled separately. Vue 3’s Composition API made that distinction unnecessary, and the boilerplate that came with it became friction.
Pinia was designed for Vue 3 from the start. It dropped mutations, kept actions, added direct state mutation, and built TypeScript inference into its core. In 2024, the Vue team officially deprecated Vuex and endorsed Pinia. In 2026, Pinia is the default.
Here is how it works and how to use it well.
The Core Concepts
Pinia has three primitives:
- State: the reactive data
- Getters: computed properties derived from state
- Actions: functions that modify state, which can be async
That’s it. No mutations, no namespacing complexity, no commit/dispatch distinction.
Defining a Store (Options API Style)
// stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
profile: null as User | null,
preferences: {
theme: 'light' as 'light' | 'dark',
language: 'en',
},
loading: false,
}),
getters: {
displayName: (state): string => {
return state.profile?.name ?? 'Guest'
},
isLoggedIn: (state): boolean => {
return state.profile !== null
},
},
actions: {
async fetchProfile(userId: string) {
this.loading = true
try {
this.profile = await api.getUser(userId)
} finally {
this.loading = false
}
},
setTheme(theme: 'light' | 'dark') {
this.preferences.theme = theme
},
logout() {
this.$reset() // Resets state to initial values
},
},
})
You can modify state directly inside actions with this. No commit('SET_LOADING', true). The TypeScript inference flows through without annotation gymnastics.
Defining a Store (Setup API Style)
Pinia also supports a Composition API style that feels closer to writing a Vue composable:
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
// Getters
const itemCount = computed(() => items.value.length)
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// Actions
function addItem(product: Product, quantity = 1) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({ ...product, quantity })
}
}
function removeItem(productId: string) {
items.value = items.value.filter(i => i.id !== productId)
}
async function applyCoupon(code: string) {
const valid = await api.validateCoupon(code)
if (valid) {
couponCode.value = code
}
return valid
}
return { items, couponCode, itemCount, subtotal, addItem, removeItem, applyCoupon }
})
The setup style has one limitation: $reset() isn’t available by default because Pinia can’t automatically know the initial values. You either implement reset yourself as an action, or use the options style for stores where you need $reset.
Using Stores in Components
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
const userStore = useUserStore()
const cartStore = useCartStore()
// Destructuring loses reactivity — don't do this:
// const { profile } = userStore ❌
// Use storeToRefs to destructure reactive state and getters:
const { profile, loading } = storeToRefs(userStore)
const { itemCount, subtotal } = storeToRefs(cartStore)
// Actions don't need storeToRefs — they're just functions:
const { fetchProfile, logout } = userStore
const { addItem } = cartStore
</script>
<template>
<div>
<p v-if="loading">Loading...</p>
<p v-else>{{ profile?.name ?? 'Guest' }}</p>
<p>Cart: {{ itemCount }} items, ${{ subtotal.toFixed(2) }}</p>
<button @click="logout">Sign out</button>
</div>
</template>
storeToRefs is the one concept that trips up developers new to Pinia. Plain destructuring works in JavaScript, but the reactive bindings are on the store object itself. storeToRefs wraps each property in a ref so it remains reactive after destructuring.
Cross-Store Interactions
Stores can use each other. Import and use inside actions:
// stores/notifications.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useNotificationStore = defineStore('notifications', {
state: () => ({
items: [] as Notification[],
}),
actions: {
async fetchUnread() {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return
this.items = await api.getNotifications(userStore.profile!.id)
},
},
})
Import the store inside the action rather than at the top of the file to avoid circular dependency issues in large applications.
Watching Store State
In components, watch store state like any other Vue reactive source:
import { watch } from 'vue'
const userStore = useUserStore()
watch(() => userStore.preferences.theme, (newTheme) => {
document.documentElement.setAttribute('data-theme', newTheme)
}, { immediate: true })
Alternatively, use Pinia’s $subscribe to react to any state change in a store:
userStore.$subscribe((mutation, state) => {
localStorage.setItem('userPreferences', JSON.stringify(state.preferences))
})
Plugins
Pinia’s plugin system handles cross-cutting concerns like persistence, undo/redo, and error logging. A plugin is a function that receives a context object and can add properties to all stores:
// plugins/persistPlugin.ts
import type { PiniaPluginContext } from 'pinia'
export function persistPlugin({ store }: PiniaPluginContext) {
const key = `store_${store.$id}`
const saved = localStorage.getItem(key)
if (saved) {
store.$patch(JSON.parse(saved))
}
store.$subscribe((_mutation, state) => {
localStorage.setItem(key, JSON.stringify(state))
})
}
// main.ts
const pinia = createPinia()
pinia.use(persistPlugin)
For production persistence, pinia-plugin-persistedstate is the standard choice. It handles serialization edge cases, selective persistence (only persist specific fields), and pluggable storage backends.
Pinia vs Vuex: The Actual Differences
| Pinia | Vuex 4 | |
|---|---|---|
| Mutations | No | Yes (required for sync changes) |
| TypeScript | First-class, inferred | Requires extra typing effort |
| Bundle size | ~1.5 KB | ~6.5 KB |
| Devtools | Full Vue Devtools support | Full Vue Devtools support |
| SSR | Supported | Supported |
| Namespacing | Flat — each store is its own unit | Explicit module nesting |
$reset() | Built-in for options style | Not built-in |
| Setup stores | Yes | No |
The namespacing question comes up most often when teams move from Vuex. Vuex modules created hierarchical namespaces like store.dispatch('users/fetchProfile'). Pinia doesn’t have namespacing — each store is identified by its ID string, and you import and use each store separately. In practice this is cleaner; the “namespace” is the store name in the import.
When Pinia Isn’t the Answer
For simple applications — a landing page with a counter, a contact form — Pinia is overkill. A single ref or reactive in a composable handles shared state fine.
// composables/useAuth.ts — sometimes this is enough
import { ref } from 'vue'
const currentUser = ref<User | null>(null)
export function useAuth() {
const login = async (credentials: Credentials) => {
currentUser.value = await api.login(credentials)
}
const logout = () => {
currentUser.value = null
}
return { currentUser, login, logout }
}
Because currentUser is declared at module scope (outside the function), it’s shared across all component instances that call useAuth(). This is module-level shared state — simple, no library required, works fine for small cases.
Pinia earns its keep when you need devtools support, SSR-safe state, store composition, plugins, or when the application has enough state that organizing it into named stores becomes valuable.
In 2026, starting a Vue 3 application means defaulting to Pinia. The question isn’t whether to use it — it’s how to structure your stores to keep them focused and avoid the catch-all store that was Vuex’s most common antipattern.
Sponsored
More from this category
More from Web Development
OAuth 2.0 and PKCE: The Web Auth Patterns Every SPA Developer Needs in 2026
Technical SEO for JavaScript Apps in 2026: What Google Actually Renders
Progressive Web Apps in 2026: What Actually Works on iOS and Android
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored