Side-by-side code comparison showing Vuex store code transforming into Pinia store syntax
18 min read Vue 3 Migration

Vuex to Pinia Migration: The Complete 2026 Guide (With Code Examples)

Pinia is now the official state management solution for Vue 3. This comprehensive guide walks you through migrating from Vuex step-by-step, with real code examples for every scenario you'll encounter.

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.

ConceptVuexPinia
Store structureSingle store with modulesMultiple independent stores
State changesMutations (required)Direct or via actions
Async operationsActions onlyActions (simpler)
TypeScriptRequires workaroundsBuilt-in support
Namespacingnamespaced: trueAutomatic (by store ID)
Composition APIuseStore() helperNative 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 pinia

Step 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 them

Cross-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

Install Pinia: npm install pinia
Register Pinia in main.ts
Convert each Vuex module to a Pinia store
Remove mutations, commit directly in actions
Replace mapState, mapGetters, etc. with storeToRefs()
Update component imports from vuex to individual stores
Add TypeScript types for state and actions
Update tests to use Pinia
Remove Vuex dependency

Need 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.

Related Guides