Skip to content

Vuex Migration

Comprehensive guide for migrating from Vuex to Pinia, including automated migration tools, step-by-step conversion process, and compatibility patterns for gradual migration.

Features

  • 🔄 Automated migration tools and scripts
  • 📋 Step-by-step migration checklist
  • 🔀 Gradual migration strategies
  • 🛠️ Vuex compatibility layer
  • 📊 State structure conversion
  • 🎯 Action and mutation mapping
  • 🧪 Testing migration strategies
  • 📦 Module system conversion
  • 🔧 DevTools integration
  • 📝 Migration validation tools

Migration Overview

Key Differences

AspectVuexPinia
Store DefinitionSingle store with modulesMultiple stores
MutationsRequired for state changesDirect state mutation
ActionsAsync operationsActions can be sync/async
GettersComputed propertiesComputed properties
ModulesNested modulesSeparate stores
TypeScriptComplex typingBuilt-in TypeScript support
DevToolsVue DevToolsVue DevTools + better DX
SSRManual setupAutomatic SSR support

Migration Tools

Automated Migration Script

typescript
// scripts/vuex-to-pinia-migration.ts
import * as fs from 'fs'
import * as path from 'path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import generate from '@babel/generator'
import * as t from '@babel/types'

interface VuexModule {
  name: string
  state: any
  mutations: Record<string, Function>
  actions: Record<string, Function>
  getters: Record<string, Function>
  modules?: Record<string, VuexModule>
}

interface MigrationConfig {
  sourceDir: string
  targetDir: string
  storeFile: string
  preserveModules: boolean
  generateTypes: boolean
  createTests: boolean
}

class VuexToPiniaMigrator {
  private config: MigrationConfig
  private vuexStore: any
  private migrationLog: string[] = []
  
  constructor(config: MigrationConfig) {
    this.config = config
  }
  
  async migrate(): Promise<void> {
    console.log('🚀 Starting Vuex to Pinia migration...')
    
    try {
      // 1. Parse existing Vuex store
      await this.parseVuexStore()
      
      // 2. Create Pinia stores
      await this.createPiniaStores()
      
      // 3. Generate TypeScript types
      if (this.config.generateTypes) {
        await this.generateTypes()
      }
      
      // 4. Create tests
      if (this.config.createTests) {
        await this.createTests()
      }
      
      // 5. Generate migration report
      await this.generateMigrationReport()
      
      console.log('✅ Migration completed successfully!')
      
    } catch (error) {
      console.error('❌ Migration failed:', error)
      throw error
    }
  }
  
  private async parseVuexStore(): Promise<void> {
    const storeFilePath = path.join(this.config.sourceDir, this.config.storeFile)
    const storeContent = fs.readFileSync(storeFilePath, 'utf-8')
    
    const ast = parse(storeContent, {
      sourceType: 'module',
      plugins: ['typescript', 'decorators-legacy']
    })
    
    this.vuexStore = this.extractStoreStructure(ast)
    this.migrationLog.push(`✓ Parsed Vuex store from ${storeFilePath}`)
  }
  
  private extractStoreStructure(ast: any): VuexModule {
    let storeStructure: VuexModule = {
      name: 'root',
      state: {},
      mutations: {},
      actions: {},
      getters: {},
      modules: {}
    }
    
    traverse(ast, {
      ObjectExpression(path) {
        const properties = path.node.properties
        
        properties.forEach((prop: any) => {
          if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
            const key = prop.key.name
            
            switch (key) {
              case 'state':
                storeStructure.state = this.extractState(prop.value)
                break
              case 'mutations':
                storeStructure.mutations = this.extractMutations(prop.value)
                break
              case 'actions':
                storeStructure.actions = this.extractActions(prop.value)
                break
              case 'getters':
                storeStructure.getters = this.extractGetters(prop.value)
                break
              case 'modules':
                storeStructure.modules = this.extractModules(prop.value)
                break
            }
          }
        })
      }
    })
    
    return storeStructure
  }
  
  private extractState(node: any): any {
    // Extract state properties
    const state: any = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          state[prop.key.name] = this.extractValue(prop.value)
        }
      })
    } else if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) {
      // Handle state as function
      const returnStatement = node.body.body?.find((stmt: any) => t.isReturnStatement(stmt))
      if (returnStatement && t.isObjectExpression(returnStatement.argument)) {
        return this.extractState(returnStatement.argument)
      }
    }
    
    return state
  }
  
  private extractMutations(node: any): Record<string, Function> {
    const mutations: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          mutations[prop.key.name] = prop.value
        }
      })
    }
    
    return mutations
  }
  
  private extractActions(node: any): Record<string, Function> {
    const actions: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          actions[prop.key.name] = prop.value
        }
      })
    }
    
    return actions
  }
  
  private extractGetters(node: any): Record<string, Function> {
    const getters: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          getters[prop.key.name] = prop.value
        }
      })
    }
    
    return getters
  }
  
  private extractModules(node: any): Record<string, VuexModule> {
    const modules: Record<string, VuexModule> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          modules[prop.key.name] = this.extractStoreStructure(prop.value)
        }
      })
    }
    
    return modules
  }
  
  private extractValue(node: any): any {
    if (t.isStringLiteral(node)) return node.value
    if (t.isNumericLiteral(node)) return node.value
    if (t.isBooleanLiteral(node)) return node.value
    if (t.isNullLiteral(node)) return null
    if (t.isArrayExpression(node)) {
      return node.elements.map((el: any) => this.extractValue(el))
    }
    if (t.isObjectExpression(node)) {
      const obj: any = {}
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          obj[prop.key.name] = this.extractValue(prop.value)
        }
      })
      return obj
    }
    return undefined
  }
  
  private async createPiniaStores(): Promise<void> {
    // Create main store
    await this.createMainStore(this.vuexStore)
    
    // Create module stores
    if (this.vuexStore.modules) {
      for (const [moduleName, moduleData] of Object.entries(this.vuexStore.modules)) {
        await this.createModuleStore(moduleName, moduleData)
      }
    }
  }
  
  private async createMainStore(storeData: VuexModule): Promise<void> {
    const storeContent = this.generatePiniaStore('main', storeData)
    const filePath = path.join(this.config.targetDir, 'stores', 'main.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.migrationLog.push(`✓ Created main store: ${filePath}`)
  }
  
  private async createModuleStore(moduleName: string, moduleData: VuexModule): Promise<void> {
    const storeContent = this.generatePiniaStore(moduleName, moduleData)
    const filePath = path.join(this.config.targetDir, 'stores', `${moduleName}.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.migrationLog.push(`✓ Created ${moduleName} store: ${filePath}`)
  }
  
  private generatePiniaStore(storeName: string, storeData: VuexModule): string {
    const stateProps = Object.keys(storeData.state)
    const mutations = Object.keys(storeData.mutations)
    const actions = Object.keys(storeData.actions)
    const getters = Object.keys(storeData.getters)
    
    return `import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Migrated from Vuex module: ${storeName}
export const use${this.capitalize(storeName)}Store = defineStore('${storeName}', () => {
  // State (migrated from Vuex state)
${this.generateStateDeclarations(storeData.state)}

  // Getters (migrated from Vuex getters)
${this.generateGetters(storeData.getters)}

  // Actions (migrated from Vuex mutations and actions)
${this.generateActions(storeData.mutations, storeData.actions)}

  return {
    // State
${stateProps.map(prop => `    ${prop}`).join(',\n')},
    
    // Getters
${getters.map(getter => `    ${getter}`).join(',\n')},
    
    // Actions
${[...mutations, ...actions].map(action => `    ${action}`).join(',\n')}
  }
})

// Type definitions
export interface ${this.capitalize(storeName)}State {
${this.generateStateInterface(storeData.state)}
}
`
  }
  
  private generateStateDeclarations(state: any): string {
    return Object.entries(state)
      .map(([key, value]) => {
        const defaultValue = JSON.stringify(value)
        return `  const ${key} = ref(${defaultValue})`
      })
      .join('\n')
  }
  
  private generateGetters(getters: Record<string, Function>): string {
    return Object.entries(getters)
      .map(([name, getter]) => {
        // Convert Vuex getter to Pinia computed
        const getterCode = this.convertVuexGetterToPinia(getter)
        return `  const ${name} = computed(() => {\n    ${getterCode}\n  })`
      })
      .join('\n\n')
  }
  
  private generateActions(mutations: Record<string, Function>, actions: Record<string, Function>): string {
    const mutationActions = Object.entries(mutations)
      .map(([name, mutation]) => {
        const actionCode = this.convertVuexMutationToPinia(mutation)
        return `  function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
    
    const regularActions = Object.entries(actions)
      .map(([name, action]) => {
        const actionCode = this.convertVuexActionToPinia(action)
        return `  async function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
    
    return [...mutationActions, ...regularActions].join('\n\n')
  }
  
  private generateStateInterface(state: any): string {
    return Object.entries(state)
      .map(([key, value]) => {
        const type = this.inferType(value)
        return `  ${key}: ${type}`
      })
      .join('\n')
  }
  
  private convertVuexGetterToPinia(getter: Function): string {
    // This is a simplified conversion - in practice, you'd need more sophisticated AST manipulation
    let code = getter.toString()
    
    // Replace Vuex getter parameters with Pinia equivalents
    code = code.replace(/\(state, getters, rootState, rootGetters\)/g, '()')
    code = code.replace(/state\./g, '')
    code = code.replace(/getters\./g, '')
    
    return code
  }
  
  private convertVuexMutationToPinia(mutation: Function): string {
    let code = mutation.toString()
    
    // Replace Vuex mutation parameters
    code = code.replace(/\(state, payload\)/g, '(payload)')
    code = code.replace(/state\./g, '')
    
    // Convert direct assignments to ref updates
    code = code.replace(/(\w+)\s*=/g, '$1.value =')
    
    return code
  }
  
  private convertVuexActionToPinia(action: Function): string {
    let code = action.toString()
    
    // Replace Vuex action parameters
    code = code.replace(/\({ commit, dispatch, state, getters, rootState, rootGetters }, payload\)/g, '(payload)')
    code = code.replace(/commit\(/g, 'this.')
    code = code.replace(/dispatch\(/g, 'this.')
    code = code.replace(/state\./g, 'this.')
    
    return code
  }
  
  private inferType(value: any): string {
    if (Array.isArray(value)) {
      if (value.length > 0) {
        const itemType = this.inferType(value[0])
        return `${itemType}[]`
      }
      return 'any[]'
    }
    
    if (value === null) return 'null'
    if (typeof value === 'object') return 'Record<string, any>'
    
    return typeof value
  }
  
  private capitalize(str: string): string {
    return str.charAt(0).toUpperCase() + str.slice(1)
  }
  
  private async ensureDirectoryExists(dirPath: string): Promise<void> {
    if (!fs.existsSync(dirPath)) {
      fs.mkdirSync(dirPath, { recursive: true })
    }
  }
  
  private async generateTypes(): Promise<void> {
    const typesContent = this.generateGlobalTypes()
    const filePath = path.join(this.config.targetDir, 'types', 'stores.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, typesContent)
    
    this.migrationLog.push(`✓ Generated types: ${filePath}`)
  }
  
  private generateGlobalTypes(): string {
    return `// Auto-generated types from Vuex migration

export interface RootState {
  // Add your root state types here
}

export interface StoreModules {
  // Add your store module types here
}

// Re-export all store types
export * from '../stores/main'
${Object.keys(this.vuexStore.modules || {})
  .map(moduleName => `export * from '../stores/${moduleName}'`)
  .join('\n')}
`
  }
  
  private async createTests(): Promise<void> {
    // Create test for main store
    await this.createStoreTest('main', this.vuexStore)
    
    // Create tests for module stores
    if (this.vuexStore.modules) {
      for (const [moduleName, moduleData] of Object.entries(this.vuexStore.modules)) {
        await this.createStoreTest(moduleName, moduleData)
      }
    }
  }
  
  private async createStoreTest(storeName: string, storeData: VuexModule): Promise<void> {
    const testContent = this.generateStoreTest(storeName, storeData)
    const filePath = path.join(this.config.targetDir, 'tests', 'stores', `${storeName}.test.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, testContent)
    
    this.migrationLog.push(`✓ Created test: ${filePath}`)
  }
  
  private generateStoreTest(storeName: string, storeData: VuexModule): string {
    const storeHook = `use${this.capitalize(storeName)}Store`
    
    return `import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ${storeHook} } from '../../stores/${storeName}'

describe('${storeName} Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('should initialize with correct default state', () => {
    const store = ${storeHook}()
    
    // Test initial state
${Object.entries(storeData.state)
  .map(([key, value]) => `    expect(store.${key}).toEqual(${JSON.stringify(value)})`)
  .join('\n')}
  })

${Object.keys(storeData.mutations)
  .map(mutationName => `  it('should handle ${mutationName} mutation', () => {
    const store = ${storeHook}()
    
    // TODO: Add test for ${mutationName}
    // store.${mutationName}(payload)
    // expect(store.someState).toBe(expectedValue)
  })`)
  .join('\n\n')}

${Object.keys(storeData.actions)
  .map(actionName => `  it('should handle ${actionName} action', async () => {
    const store = ${storeHook}()
    
    // TODO: Add test for ${actionName}
    // await store.${actionName}(payload)
    // expect(store.someState).toBe(expectedValue)
  })`)
  .join('\n\n')}

${Object.keys(storeData.getters)
  .map(getterName => `  it('should compute ${getterName} getter correctly', () => {
    const store = ${storeHook}()
    
    // TODO: Add test for ${getterName}
    // expect(store.${getterName}).toBe(expectedValue)
  })`)
  .join('\n\n')}
})
`
  }
  
  private async generateMigrationReport(): Promise<void> {
    const report = `# Vuex to Pinia Migration Report

Generated on: ${new Date().toISOString()}

## Migration Summary

${this.migrationLog.map(log => `- ${log}`).join('\n')}

## Manual Steps Required

1. Review generated stores and update business logic
2. Update component imports from Vuex to Pinia
3. Replace \`mapState\`, \`mapGetters\`, \`mapActions\` with Pinia equivalents
4. Update router guards and middleware
5. Test all functionality thoroughly
6. Update documentation

## Breaking Changes

- Mutations are now direct state modifications
- Actions can be synchronous or asynchronous
- No more \`commit\` and \`dispatch\` - call actions directly
- Module namespacing is replaced with separate stores

## Next Steps

1. Run tests: \`npm run test\`
2. Update components to use new stores
3. Remove Vuex dependencies
4. Update build configuration
`
    
    const filePath = path.join(this.config.targetDir, 'MIGRATION_REPORT.md')
    fs.writeFileSync(filePath, report)
    
    console.log(`📋 Migration report generated: ${filePath}`)
  }
}

// Usage
export async function migrateVuexToPinia(config: MigrationConfig) {
  const migrator = new VuexToPiniaMigrator(config)
  await migrator.migrate()
}

// CLI usage
if (require.main === module) {
  const config: MigrationConfig = {
    sourceDir: './src',
    targetDir: './src',
    storeFile: 'store/index.js',
    preserveModules: true,
    generateTypes: true,
    createTests: true
  }
  
  migrateVuexToPinia(config).catch(console.error)
}

Vuex Compatibility Layer

typescript
// utils/vuex-compat.ts
import { inject, InjectionKey } from 'vue'
import type { Store } from 'pinia'

// Compatibility layer for gradual migration
export interface VuexCompatStore {
  state: any
  getters: any
  commit: (type: string, payload?: any) => void
  dispatch: (type: string, payload?: any) => Promise<any>
}

// Create a Vuex-like interface for Pinia stores
export function createVuexCompat(piniaStore: any): VuexCompatStore {
  return {
    get state() {
      return piniaStore.$state
    },
    
    get getters() {
      // Map computed properties as getters
      const getters: any = {}
      for (const key in piniaStore) {
        if (typeof piniaStore[key] === 'function' && key.startsWith('get')) {
          getters[key] = piniaStore[key]
        }
      }
      return getters
    },
    
    commit(type: string, payload?: any) {
      // Map mutations to direct method calls
      if (typeof piniaStore[type] === 'function') {
        piniaStore[type](payload)
      } else {
        console.warn(`Mutation ${type} not found in store`)
      }
    },
    
    async dispatch(type: string, payload?: any) {
      // Map actions to direct method calls
      if (typeof piniaStore[type] === 'function') {
        return await piniaStore[type](payload)
      } else {
        console.warn(`Action ${type} not found in store`)
        return Promise.resolve()
      }
    }
  }
}

// Vuex-style helpers for Pinia
export function mapState(storeId: string, states: string[]) {
  return states.reduce((mapped, state) => {
    mapped[state] = function(this: any) {
      const store = this.$pinia.state.value[storeId]
      return store ? store[state] : undefined
    }
    return mapped
  }, {} as any)
}

export function mapGetters(storeId: string, getters: string[]) {
  return getters.reduce((mapped, getter) => {
    mapped[getter] = function(this: any) {
      const store = this.$pinia._s.get(storeId)
      return store ? store[getter] : undefined
    }
    return mapped
  }, {} as any)
}

export function mapActions(storeId: string, actions: string[]) {
  return actions.reduce((mapped, action) => {
    mapped[action] = function(this: any, payload?: any) {
      const store = this.$pinia._s.get(storeId)
      return store ? store[action](payload) : Promise.resolve()
    }
    return mapped
  }, {} as any)
}

export function mapMutations(storeId: string, mutations: string[]) {
  return mutations.reduce((mapped, mutation) => {
    mapped[mutation] = function(this: any, payload?: any) {
      const store = this.$pinia._s.get(storeId)
      if (store && typeof store[mutation] === 'function') {
        store[mutation](payload)
      }
    }
    return mapped
  }, {} as any)
}

Step-by-Step Migration Examples

1. Simple Vuex Store

Before (Vuex):

javascript
// store/modules/counter.js
export default {
  namespaced: true,
  state: {
    count: 0,
    history: []
  },
  mutations: {
    INCREMENT(state) {
      state.count++
      state.history.push(`Incremented to ${state.count}`)
    },
    DECREMENT(state) {
      state.count--
      state.history.push(`Decremented to ${state.count}`)
    },
    SET_COUNT(state, value) {
      state.count = value
      state.history.push(`Set to ${value}`)
    }
  },
  actions: {
    async incrementAsync({ commit }, delay = 1000) {
      await new Promise(resolve => setTimeout(resolve, delay))
      commit('INCREMENT')
    },
    reset({ commit }) {
      commit('SET_COUNT', 0)
    }
  },
  getters: {
    doubleCount: state => state.count * 2,
    isPositive: state => state.count > 0,
    lastAction: state => state.history[state.history.length - 1]
  }
}

After (Pinia):

typescript
// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  const history = ref<string[]>([])
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const isPositive = computed(() => count.value > 0)
  const lastAction = computed(() => history.value[history.value.length - 1])
  
  // Actions (mutations + actions combined)
  function increment() {
    count.value++
    history.value.push(`Incremented to ${count.value}`)
  }
  
  function decrement() {
    count.value--
    history.value.push(`Decremented to ${count.value}`)
  }
  
  function setCount(value: number) {
    count.value = value
    history.value.push(`Set to ${value}`)
  }
  
  async function incrementAsync(delay = 1000) {
    await new Promise(resolve => setTimeout(resolve, delay))
    increment()
  }
  
  function reset() {
    setCount(0)
  }
  
  return {
    // State
    count,
    history,
    
    // Getters
    doubleCount,
    isPositive,
    lastAction,
    
    // Actions
    increment,
    decrement,
    setCount,
    incrementAsync,
    reset
  }
})

2. Component Migration

Before (Vuex):

vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    <p>Last Action: {{ lastAction }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
    <button @click="incrementAsync">+ Async</button>
    <button @click="reset">Reset</button>
  </div>
</template>

<script>
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'

export default {
  computed: {
    ...mapState('counter', ['count']),
    ...mapGetters('counter', ['doubleCount', 'lastAction'])
  },
  methods: {
    ...mapMutations('counter', ['increment', 'decrement']),
    ...mapActions('counter', ['incrementAsync', 'reset'])
  }
}
</script>

After (Pinia):

vue
<template>
  <div>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <p>Last Action: {{ counter.lastAction }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
    <button @click="counter.incrementAsync">+ Async</button>
    <button @click="counter.reset">Reset</button>
  </div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

3. Complex Store with Modules

Before (Vuex):

javascript
// store/modules/user.js
export default {
  namespaced: true,
  state: {
    currentUser: null,
    preferences: {},
    isAuthenticated: false
  },
  mutations: {
    SET_USER(state, user) {
      state.currentUser = user
      state.isAuthenticated = !!user
    },
    SET_PREFERENCES(state, preferences) {
      state.preferences = preferences
    },
    LOGOUT(state) {
      state.currentUser = null
      state.isAuthenticated = false
      state.preferences = {}
    }
  },
  actions: {
    async login({ commit, dispatch }, credentials) {
      try {
        const user = await api.login(credentials)
        commit('SET_USER', user)
        await dispatch('loadPreferences')
        return user
      } catch (error) {
        throw error
      }
    },
    async loadPreferences({ commit, state }) {
      if (state.currentUser) {
        const preferences = await api.getUserPreferences(state.currentUser.id)
        commit('SET_PREFERENCES', preferences)
      }
    },
    logout({ commit }) {
      commit('LOGOUT')
      // Clear other stores
      commit('posts/CLEAR_POSTS', null, { root: true })
    }
  },
  getters: {
    userName: state => state.currentUser?.name || 'Guest',
    userRole: state => state.currentUser?.role || 'guest',
    hasPermission: state => permission => {
      return state.currentUser?.permissions?.includes(permission) || false
    }
  }
}

After (Pinia):

typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { usePostsStore } from './posts'
import * as api from '@/api'

interface User {
  id: string
  name: string
  role: string
  permissions: string[]
}

export const useUserStore = defineStore('user', () => {
  // State
  const currentUser = ref<User | null>(null)
  const preferences = ref<Record<string, any>>({})
  const isAuthenticated = ref(false)
  
  // Getters
  const userName = computed(() => currentUser.value?.name || 'Guest')
  const userRole = computed(() => currentUser.value?.role || 'guest')
  const hasPermission = computed(() => {
    return (permission: string) => {
      return currentUser.value?.permissions?.includes(permission) || false
    }
  })
  
  // Actions
  function setUser(user: User | null) {
    currentUser.value = user
    isAuthenticated.value = !!user
  }
  
  function setPreferences(newPreferences: Record<string, any>) {
    preferences.value = newPreferences
  }
  
  async function login(credentials: { email: string; password: string }) {
    try {
      const user = await api.login(credentials)
      setUser(user)
      await loadPreferences()
      return user
    } catch (error) {
      throw error
    }
  }
  
  async function loadPreferences() {
    if (currentUser.value) {
      const userPreferences = await api.getUserPreferences(currentUser.value.id)
      setPreferences(userPreferences)
    }
  }
  
  function logout() {
    setUser(null)
    setPreferences({})
    
    // Clear other stores
    const postsStore = usePostsStore()
    postsStore.clearPosts()
  }
  
  return {
    // State
    currentUser,
    preferences,
    isAuthenticated,
    
    // Getters
    userName,
    userRole,
    hasPermission,
    
    // Actions
    setUser,
    setPreferences,
    login,
    loadPreferences,
    logout
  }
})

Migration Checklist

Pre-Migration

  • [ ] Audit existing Vuex store structure
  • [ ] Identify module dependencies
  • [ ] Create backup of current implementation
  • [ ] Set up Pinia in the project
  • [ ] Plan migration strategy (gradual vs. complete)

During Migration

  • [ ] Convert state to reactive refs
  • [ ] Merge mutations and actions into actions
  • [ ] Convert getters to computed properties
  • [ ] Update module structure to separate stores
  • [ ] Handle cross-store dependencies
  • [ ] Update component imports and usage

Post-Migration

  • [ ] Update all component imports
  • [ ] Replace Vuex helpers with Pinia equivalents
  • [ ] Update router guards and middleware
  • [ ] Test all functionality
  • [ ] Update documentation
  • [ ] Remove Vuex dependencies
  • [ ] Update build configuration

Testing Migration

typescript
// tests/migration.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
import { useUserStore } from '@/stores/user'

describe('Vuex to Pinia Migration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  describe('Counter Store Migration', () => {
    it('should maintain same functionality as Vuex version', () => {
      const counter = useCounterStore()
      
      // Test initial state
      expect(counter.count).toBe(0)
      expect(counter.history).toEqual([])
      
      // Test mutations -> actions
      counter.increment()
      expect(counter.count).toBe(1)
      expect(counter.history).toContain('Incremented to 1')
      
      counter.decrement()
      expect(counter.count).toBe(0)
      expect(counter.history).toContain('Decremented to 0')
      
      // Test getters
      expect(counter.doubleCount).toBe(0)
      expect(counter.isPositive).toBe(false)
    })
    
    it('should handle async actions correctly', async () => {
      const counter = useCounterStore()
      
      await counter.incrementAsync(100)
      expect(counter.count).toBe(1)
    })
  })

  describe('User Store Migration', () => {
    it('should handle authentication flow', async () => {
      const user = useUserStore()
      
      expect(user.isAuthenticated).toBe(false)
      expect(user.userName).toBe('Guest')
      
      // Mock login
      const mockUser = {
        id: '1',
        name: 'John Doe',
        role: 'admin',
        permissions: ['read', 'write']
      }
      
      user.setUser(mockUser)
      
      expect(user.isAuthenticated).toBe(true)
      expect(user.userName).toBe('John Doe')
      expect(user.hasPermission('read')).toBe(true)
    })
  })
})

Best Practices

  1. Gradual Migration: Migrate one module at a time
  2. Maintain Compatibility: Use compatibility layer during transition
  3. Test Thoroughly: Ensure all functionality works after migration
  4. Update Documentation: Keep documentation current during migration
  5. Team Communication: Coordinate with team members on migration progress
  6. Performance Monitoring: Monitor performance before and after migration
  7. Rollback Plan: Have a plan to rollback if issues arise
  8. Training: Ensure team understands Pinia concepts and patterns

This migration guide provides a comprehensive approach to moving from Vuex to Pinia while maintaining application stability and functionality.

Released under the MIT License.