If you're migrating from Vue 2 to Vue 3 (see our complete DIY migration roadmap), you've probably heard that Vuex is being replaced. It's true: Pinia is now the officially recommended state management library for Vue. Vuex 4 exists for Vue 3 compatibility, but it's in maintenance mode—no new features, just bug fixes.
The good news? Pinia is simpler, more intuitive, and has first-class TypeScript support. The migration is straightforward once you understand the key differences. This guide covers everything you need to know.
1. Why Pinia Replaced Vuex
Pinia wasn't created to compete with Vuex—it was created to replace it. The Vue core team officially recommends Pinia for all new projects, and the Vuex documentation itself points to Pinia as the successor.
What Pinia Does Better
No more mutations
Actions can directly modify state. The mutation/action/commit pattern is gone.
First-class TypeScript support
Full type inference out of the box. No manual typing or workarounds needed.
Modular by default
No more namespaced modules. Each store is independent and tree-shakeable.
Smaller bundle size
Pinia is ~1KB gzipped. Vuex is ~10KB. That's a 90% reduction.
DevTools integration
Full Vue DevTools support with time-travel debugging, just like Vuex.
What About Vuex 4?
Vuex 4 was released for Vue 3 compatibility, but it's in maintenance mode. No new features will be added. If you're already doing a Vue 3 migration, migrating to Pinia at the same time is strongly recommended. You don't want to migrate twice.
2. Key Differences: Vuex vs Pinia
Before diving into code, let's understand the conceptual differences. Pinia isn't just "Vuex with a different API"—it's a fundamentally simpler approach to state management.
| Concept | Vuex | Pinia |
|---|---|---|
| Store structure | Single store with modules | Multiple independent stores |
| State changes | Mutations (required) | Direct or via actions |
| Async operations | Actions only | Actions (simpler) |
| TypeScript | Requires workarounds | Built-in support |
| Namespacing | namespaced: true | Automatic (by store ID) |
| Composition API | useStore() helper | Native support |
Vuex Concepts You'll Lose
- ×
mutations- Gone entirely - ×
commit()- No longer needed - ×
dispatch()- Just call actions directly - ×
mapState,mapGetters, etc.
Pinia Concepts You'll Gain
- +
$patch()- Batch multiple state changes - +
$reset()- Reset to initial state - +
$subscribe()- Watch state changes - +
storeToRefs()- Reactive destructuring
3. Setting Up Pinia
Before migrating stores, you need to install and configure Pinia. The good news: Pinia and Vuex can coexist during migration, so you can migrate incrementally.
Step 1: Install Pinia
# npm
npm install pinia
# yarn
yarn add pinia
# pnpm
pnpm add piniaStep 2: Register Pinia in Your App
// main.ts (or main.js)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
// Create Pinia instance
const pinia = createPinia()
// Use Pinia (can coexist with Vuex during migration)
app.use(pinia)
// If you still have Vuex stores to migrate:
// app.use(store)
app.mount('#app')Pro Tip: Incremental Migration
You can run Pinia and Vuex side-by-side. Start by creating new features with Pinia stores while gradually migrating existing Vuex modules. This reduces risk and allows testing along the way.
4. Migrating Your First Store
Let's migrate a real Vuex store to Pinia. We'll use a common example: a user authentication store.
Before: Vuex Store
// store/modules/user.js (Vuex)
export default {
namespaced: true,
state: () => ({
user: null,
isAuthenticated: false,
loading: false,
error: null
}),
getters: {
fullName: (state) => {
if (!state.user) return ''
return `${state.user.firstName} ${state.user.lastName}`
},
isAdmin: (state) => {
return state.user?.role === 'admin'
}
},
mutations: {
SET_USER(state, user) {
state.user = user
state.isAuthenticated = !!user
},
SET_LOADING(state, loading) {
state.loading = loading
},
SET_ERROR(state, error) {
state.error = error
},
CLEAR_USER(state) {
state.user = null
state.isAuthenticated = false
}
},
actions: {
async login({ commit }, credentials) {
commit('SET_LOADING', true)
commit('SET_ERROR', null)
try {
const response = await api.login(credentials)
commit('SET_USER', response.data.user)
return response.data
} catch (error) {
commit('SET_ERROR', error.message)
throw error
} finally {
commit('SET_LOADING', false)
}
},
async logout({ commit }) {
await api.logout()
commit('CLEAR_USER')
},
async fetchCurrentUser({ commit }) {
commit('SET_LOADING', true)
try {
const response = await api.getCurrentUser()
commit('SET_USER', response.data.user)
} catch (error) {
commit('SET_ERROR', error.message)
} finally {
commit('SET_LOADING', false)
}
}
}
}67 lines of code. 4 mutations just to set values. Lots of boilerplate.
After: Pinia Store
// stores/user.ts (Pinia)
import { defineStore } from 'pinia'
import { api } from '@/services/api'
interface User {
id: number
firstName: string
lastName: string
email: string
role: 'user' | 'admin'
}
interface UserState {
user: User | null
loading: boolean
error: string | null
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
user: null,
loading: false,
error: null
}),
getters: {
isAuthenticated: (state) => !!state.user,
fullName: (state) => {
if (!state.user) return ''
return `${state.user.firstName} ${state.user.lastName}`
},
isAdmin: (state) => state.user?.role === 'admin'
},
actions: {
async login(credentials: { email: string; password: string }) {
this.loading = true
this.error = null
try {
const response = await api.login(credentials)
this.user = response.data.user // Direct mutation!
return response.data
} catch (error: any) {
this.error = error.message
throw error
} finally {
this.loading = false
}
},
async logout() {
await api.logout()
this.$reset() // Built-in reset to initial state!
},
async fetchCurrentUser() {
this.loading = true
try {
const response = await api.getCurrentUser()
this.user = response.data.user
} catch (error: any) {
this.error = error.message
} finally {
this.loading = false
}
}
}
})58 lines of code. Zero mutations. Full TypeScript support. Cleaner, more readable.
Using the Store
Vuex Usage
// Component with Vuex
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['user', 'loading']),
...mapGetters('user', ['fullName', 'isAdmin'])
},
methods: {
...mapActions('user', ['login', 'logout'])
}
}
// Or with Composition API
import { useStore } from 'vuex'
import { computed } from 'vue'
const store = useStore()
const user = computed(() => store.state.user.user)
const login = (creds) => store.dispatch('user/login', creds)Pinia Usage
// Component with Pinia
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
// In setup() or <script setup>
const userStore = useUserStore()
// Reactive state (use storeToRefs for reactivity)
const { user, loading, fullName, isAdmin } = storeToRefs(userStore)
// Actions are just methods
const handleLogin = async (creds) => {
await userStore.login(creds)
}
// Or call directly
userStore.logout()5. Converting Vuex Modules to Pinia Stores
In Vuex, you have one store with multiple namespaced modules. In Pinia, you have multiple independent stores. Here's how to think about the conversion:
Vuex Module Structure → Pinia Store Structure
Vuex (store/index.js)
import Vue from 'vue'
import Vuex from 'vuex'
import user from './modules/user'
import products from './modules/products'
import cart from './modules/cart'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
user,
products,
cart
}
})Pinia (stores/)
// stores/user.ts
export const useUserStore = defineStore('user', { ... })
// stores/products.ts
export const useProductsStore = defineStore('products', { ... })
// stores/cart.ts
export const useCartStore = defineStore('cart', { ... })
// No central index needed!
// Import stores directly where you need themCross-Store Communication
In Vuex, modules could access root state or dispatch to other modules. In Pinia, stores simply import and use each other:
// stores/cart.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductsStore } from './products'
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
actions: {
addToCart(productId: number) {
// Access other stores directly
const userStore = useUserStore()
const productsStore = useProductsStore()
if (!userStore.isAuthenticated) {
throw new Error('Must be logged in to add to cart')
}
const product = productsStore.getProductById(productId)
this.items.push({ product, quantity: 1 })
}
}
})6. TypeScript Migration
One of Pinia's biggest advantages is first-class TypeScript support. If you're migrating a TypeScript project, or planning to add TypeScript, Pinia makes it painless.
Full Type Inference
// stores/products.ts
import { defineStore } from 'pinia'
interface Product {
id: number
name: string
price: number
inStock: boolean
}
export const useProductsStore = defineStore('products', {
state: () => ({
products: [] as Product[], // Type annotation
selectedId: null as number | null
}),
getters: {
// Return type is inferred automatically
selectedProduct(state) {
return state.products.find(p => p.id === state.selectedId)
},
// Or be explicit if you prefer
inStockProducts(state): Product[] {
return state.products.filter(p => p.inStock)
},
// Getters can use other getters with `this`
selectedProductName(): string {
return this.selectedProduct?.name ?? 'None selected'
}
},
actions: {
// Parameters are typed, `this` is fully typed
async fetchProducts() {
const response = await api.get<Product[]>('/products')
this.products = response.data
},
selectProduct(id: number) {
this.selectedId = id
}
}
})Setup Syntax (Alternative)
Pinia also supports a Composition API-style "setup" syntax for stores. Some teams prefer this for consistency with <script setup>:
// stores/counter.ts (Setup Syntax)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// State = refs
const count = ref(0)
// Getters = computed
const doubleCount = computed(() => count.value * 2)
// Actions = functions
function increment() {
count.value++
}
async function incrementAsync() {
await new Promise(r => setTimeout(r, 1000))
count.value++
}
// Must return all state, getters, and actions
return { count, doubleCount, increment, incrementAsync }
})7. Common Gotchas and Solutions
These are the issues we see most often when teams migrate from Vuex to Pinia:
Gotcha #1: Destructuring Loses Reactivity
If you destructure state or getters directly from a store, they lose reactivity:
Wrong
const store = useUserStore()
const { user } = store // Not reactive!Correct
const store = useUserStore()
const { user } = storeToRefs(store) // Reactive!Gotcha #2: Using Store Before Pinia Is Created
You can't use a store outside of a component before app.use(pinia) is called:
Wrong
// router/index.ts
const userStore = useUserStore() // Error!Correct
// router/index.ts
router.beforeEach((to) => {
const userStore = useUserStore() // Inside guard = OK
})Gotcha #3: $reset() Only Works with Options Syntax
The $reset() method only works if you used the Options syntax (not Setup syntax). For Setup stores, create your own reset function:
// Setup syntax: manual reset
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eduardo')
function $reset() {
count.value = 0
name.value = 'Eduardo'
}
return { count, name, $reset }
})Gotcha #4: Getter Dependencies on Other Getters
In Pinia, use this to access other getters:
getters: {
fullName: (state) => `${state.firstName} ${state.lastName}`,
// Access other getters with `this`
greeting(): string {
return `Hello, ${this.fullName}!` // Note: `this`, not `state`
}
}8. Testing Pinia Stores
Testing Pinia stores is simpler than testing Vuex. Here's how to set up your tests:
Unit Testing a Store
// stores/user.spec.ts
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from './user'
import { vi, describe, it, expect, beforeEach } from 'vitest'
describe('User Store', () => {
beforeEach(() => {
// Create a fresh Pinia instance for each test
setActivePinia(createPinia())
})
it('starts with no user', () => {
const store = useUserStore()
expect(store.user).toBeNull()
expect(store.isAuthenticated).toBe(false)
})
it('logs in a user', async () => {
const store = useUserStore()
// Mock the API
vi.spyOn(api, 'login').mockResolvedValue({
data: { user: { id: 1, firstName: 'John', lastName: 'Doe' } }
})
await store.login({ email: 'john@example.com', password: 'secret' })
expect(store.user).toEqual({ id: 1, firstName: 'John', lastName: 'Doe' })
expect(store.isAuthenticated).toBe(true)
expect(store.fullName).toBe('John Doe')
})
it('resets state on logout', async () => {
const store = useUserStore()
store.user = { id: 1, firstName: 'John', lastName: 'Doe' }
await store.logout()
expect(store.user).toBeNull()
})
})Testing Components with Stores
// components/UserProfile.spec.ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import UserProfile from './UserProfile.vue'
it('displays user name', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: {
user: { firstName: 'John', lastName: 'Doe' }
}
}
})
]
}
})
expect(wrapper.text()).toContain('John Doe')
})Quick Migration Checklist
npm install piniamain.tsmapState, mapGetters, etc. with storeToRefs()vuex to individual storesNeed Help with Your Migration?
Vuex to Pinia is just one piece of the Vue 3 migration puzzle. Get a comprehensive audit that covers your entire codebase—state management, components, dependencies, and more. Fixed-price quote included.
✓ Fixed-price guarantee ✓ Covers Vuex, Vuetify, and all dependencies ✓ 7-day turnaround
Conclusion
Migrating from Vuex to Pinia is one of the more straightforward parts of a Vue 3 migration. The APIs are cleaner, TypeScript support is built-in, and the mental model is simpler. No more mutations. No more commit. Just state, getters, and actions.
The key is to migrate incrementally. You can run Pinia and Vuex side-by-side, migrating one store at a time. Test each store before moving to the next. By the time you're done, you'll have a cleaner, more maintainable state management layer.
If you're doing a full Vue 2 to Vue 3 migration, Pinia is just one piece of the puzzle. Check out our DIY Migration Roadmap for the complete picture, or get in touch if you'd like expert help.
