Skip to content

Redux Migration

Comprehensive guide for migrating from Redux to Pinia, including automated migration tools, pattern conversion strategies, and compatibility layers for gradual migration from React/Redux patterns to Vue/Pinia.

Features

  • 🔄 Redux to Pinia pattern conversion
  • 📋 Automated migration utilities
  • 🔀 Gradual migration strategies
  • 🛠️ Redux compatibility layer
  • 📊 State structure transformation
  • 🎯 Action and reducer mapping
  • 🧪 Testing migration patterns
  • 📦 Middleware conversion
  • 🔧 DevTools integration
  • 📝 Type safety migration

Migration Overview

Key Differences

AspectReduxPinia
Store StructureSingle store with reducersMultiple stores
State UpdatesImmutable with reducersDirect mutation or actions
ActionsPlain objects with typesFunctions with parameters
MiddlewareComplex middleware chainSimple plugins
SelectorsManual memoizationComputed properties
Side EffectsRedux-Saga/ThunkAsync actions
TypeScriptComplex type definitionsBuilt-in TypeScript support
DevToolsRedux DevToolsVue DevTools
BoilerplateHigh boilerplateMinimal boilerplate

Migration Tools

Redux to Pinia Converter

typescript
// tools/redux-to-pinia-converter.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 ReduxSlice {
  name: string
  initialState: any
  reducers: Record<string, Function>
  actions: Record<string, any>
  selectors: Record<string, Function>
  middleware?: Function[]
}

interface ConversionConfig {
  sourceDir: string
  targetDir: string
  slicesDir: string
  generateTypes: boolean
  createTests: boolean
  preserveMiddleware: boolean
}

class ReduxToPiniaConverter {
  private config: ConversionConfig
  private reduxSlices: Map<string, ReduxSlice> = new Map()
  private conversionLog: string[] = []
  
  constructor(config: ConversionConfig) {
    this.config = config
  }
  
  async convert(): Promise<void> {
    console.log('🚀 Starting Redux to Pinia conversion...')
    
    try {
      // 1. Parse Redux slices
      await this.parseReduxSlices()
      
      // 2. Convert to Pinia stores
      await this.convertToPiniaStores()
      
      // 3. Generate TypeScript types
      if (this.config.generateTypes) {
        await this.generateTypes()
      }
      
      // 4. Convert middleware to plugins
      if (this.config.preserveMiddleware) {
        await this.convertMiddleware()
      }
      
      // 5. Create tests
      if (this.config.createTests) {
        await this.createTests()
      }
      
      // 6. Generate conversion report
      await this.generateConversionReport()
      
      console.log('✅ Conversion completed successfully!')
      
    } catch (error) {
      console.error('❌ Conversion failed:', error)
      throw error
    }
  }
  
  private async parseReduxSlices(): Promise<void> {
    const slicesPath = path.join(this.config.sourceDir, this.config.slicesDir)
    const sliceFiles = fs.readdirSync(slicesPath).filter(file => 
      file.endsWith('.js') || file.endsWith('.ts')
    )
    
    for (const file of sliceFiles) {
      const filePath = path.join(slicesPath, file)
      const sliceName = path.basename(file, path.extname(file))
      
      const slice = await this.parseReduxSlice(filePath, sliceName)
      this.reduxSlices.set(sliceName, slice)
      
      this.conversionLog.push(`✓ Parsed Redux slice: ${sliceName}`)
    }
  }
  
  private async parseReduxSlice(filePath: string, sliceName: string): Promise<ReduxSlice> {
    const content = fs.readFileSync(filePath, 'utf-8')
    const ast = parse(content, {
      sourceType: 'module',
      plugins: ['typescript', 'jsx']
    })
    
    const slice: ReduxSlice = {
      name: sliceName,
      initialState: {},
      reducers: {},
      actions: {},
      selectors: {}
    }
    
    traverse(ast, {
      // Parse createSlice calls
      CallExpression(path) {
        if (this.isCreateSliceCall(path.node)) {
          this.extractSliceData(path.node, slice)
        }
      },
      
      // Parse action creators
      VariableDeclarator(path) {
        if (this.isActionCreator(path.node)) {
          this.extractActionCreator(path.node, slice)
        }
      },
      
      // Parse selectors
      ExportNamedDeclaration(path) {
        if (this.isSelector(path.node)) {
          this.extractSelector(path.node, slice)
        }
      }
    })
    
    return slice
  }
  
  private isCreateSliceCall(node: any): boolean {
    return (
      t.isCallExpression(node) &&
      t.isIdentifier(node.callee) &&
      node.callee.name === 'createSlice'
    )
  }
  
  private extractSliceData(node: any, slice: ReduxSlice): void {
    const config = node.arguments[0]
    
    if (t.isObjectExpression(config)) {
      config.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          switch (prop.key.name) {
            case 'name':
              if (t.isStringLiteral(prop.value)) {
                slice.name = prop.value.value
              }
              break
            case 'initialState':
              slice.initialState = this.extractValue(prop.value)
              break
            case 'reducers':
              slice.reducers = this.extractReducers(prop.value)
              break
          }
        }
      })
    }
  }
  
  private extractReducers(node: any): Record<string, Function> {
    const reducers: Record<string, Function> = {}
    
    if (t.isObjectExpression(node)) {
      node.properties.forEach((prop: any) => {
        if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
          reducers[prop.key.name] = prop.value
        }
      })
    }
    
    return reducers
  }
  
  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 isActionCreator(node: any): boolean {
    return (
      t.isVariableDeclarator(node) &&
      t.isCallExpression(node.init) &&
      t.isIdentifier(node.init.callee) &&
      node.init.callee.name === 'createAction'
    )
  }
  
  private extractActionCreator(node: any, slice: ReduxSlice): void {
    if (t.isIdentifier(node.id) && t.isCallExpression(node.init)) {
      const actionName = node.id.name
      slice.actions[actionName] = node.init
    }
  }
  
  private isSelector(node: any): boolean {
    return (
      t.isExportNamedDeclaration(node) &&
      t.isVariableDeclaration(node.declaration)
    )
  }
  
  private extractSelector(node: any, slice: ReduxSlice): void {
    if (t.isVariableDeclaration(node.declaration)) {
      node.declaration.declarations.forEach((decl: any) => {
        if (t.isVariableDeclarator(decl) && t.isIdentifier(decl.id)) {
          const selectorName = decl.id.name
          if (selectorName.startsWith('select')) {
            slice.selectors[selectorName] = decl.init
          }
        }
      })
    }
  }
  
  private async convertToPiniaStores(): Promise<void> {
    for (const [sliceName, slice] of this.reduxSlices) {
      await this.createPiniaStore(sliceName, slice)
    }
  }
  
  private async createPiniaStore(sliceName: string, slice: ReduxSlice): Promise<void> {
    const storeContent = this.generatePiniaStore(sliceName, slice)
    const filePath = path.join(this.config.targetDir, 'stores', `${sliceName}.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, storeContent)
    
    this.conversionLog.push(`✓ Created Pinia store: ${filePath}`)
  }
  
  private generatePiniaStore(sliceName: string, slice: ReduxSlice): string {
    const stateProps = Object.keys(slice.initialState)
    const reducers = Object.keys(slice.reducers)
    const selectors = Object.keys(slice.selectors)
    
    return `import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// Converted from Redux slice: ${sliceName}
export const use${this.capitalize(sliceName)}Store = defineStore('${sliceName}', () => {
  // State (converted from Redux initialState)
${this.generateStateDeclarations(slice.initialState)}

  // Getters (converted from Redux selectors)
${this.generateGetters(slice.selectors)}

  // Actions (converted from Redux reducers)
${this.generateActions(slice.reducers)}

  return {
    // State
${stateProps.map(prop => `    ${prop}`).join(',\n')},
    
    // Getters
${selectors.map(selector => `    ${selector}`).join(',\n')},
    
    // Actions
${reducers.map(reducer => `    ${reducer}`).join(',\n')}
  }
})

// Type definitions
export interface ${this.capitalize(sliceName)}State {
${this.generateStateInterface(slice.initialState)}
}

// Action payload types
${this.generateActionTypes(slice.reducers)}
`
  }
  
  private generateStateDeclarations(initialState: any): string {
    return Object.entries(initialState)
      .map(([key, value]) => {
        const defaultValue = JSON.stringify(value)
        return `  const ${key} = ref(${defaultValue})`
      })
      .join('\n')
  }
  
  private generateGetters(selectors: Record<string, Function>): string {
    return Object.entries(selectors)
      .map(([name, selector]) => {
        const getterCode = this.convertReduxSelectorToPinia(selector)
        return `  const ${name} = computed(() => {\n    ${getterCode}\n  })`
      })
      .join('\n\n')
  }
  
  private generateActions(reducers: Record<string, Function>): string {
    return Object.entries(reducers)
      .map(([name, reducer]) => {
        const actionCode = this.convertReduxReducerToPinia(reducer)
        return `  function ${name}(payload?: any) {\n    ${actionCode}\n  }`
      })
      .join('\n\n')
  }
  
  private generateStateInterface(initialState: any): string {
    return Object.entries(initialState)
      .map(([key, value]) => {
        const type = this.inferType(value)
        return `  ${key}: ${type}`
      })
      .join('\n')
  }
  
  private generateActionTypes(reducers: Record<string, Function>): string {
    return Object.keys(reducers)
      .map(reducerName => {
        const typeName = `${this.capitalize(reducerName)}Payload`
        return `export interface ${typeName} {\n  // TODO: Define payload type for ${reducerName}\n  [key: string]: any\n}`
      })
      .join('\n\n')
  }
  
  private convertReduxSelectorToPinia(selector: Function): string {
    let code = selector.toString()
    
    // Convert Redux selector pattern to Pinia
    code = code.replace(/state => state\.(\w+)/g, '$1.value')
    code = code.replace(/\(state\) =>/g, '() =>')
    code = code.replace(/state\./g, '')
    
    return code
  }
  
  private convertReduxReducerToPinia(reducer: Function): string {
    let code = reducer.toString()
    
    // Convert Redux reducer pattern to Pinia action
    code = code.replace(/\(state, action\)/g, '(payload)')
    code = code.replace(/state\./g, '')
    code = code.replace(/action\.payload/g, 'payload')
    
    // Convert immutable updates to direct mutations
    code = code.replace(/return {\s*\.\.\.state,\s*([^}]+)\s*}/g, (match, updates) => {
      const assignments = updates.split(',').map((update: string) => {
        const [key, value] = update.split(':').map((s: string) => s.trim())
        return `${key}.value = ${value}`
      })
      return assignments.join('\n    ')
    })
    
    // Handle array updates
    code = code.replace(/return \[\s*\.\.\.state,\s*([^\]]+)\s*\]/g, (match, item) => {
      return `state.value.push(${item})`
    })
    
    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 convertMiddleware(): Promise<void> {
    // Convert Redux middleware to Pinia plugins
    const middlewareContent = this.generatePiniaPlugins()
    const filePath = path.join(this.config.targetDir, 'plugins', 'redux-compat.ts')
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, middlewareContent)
    
    this.conversionLog.push(`✓ Created Pinia plugins: ${filePath}`)
  }
  
  private generatePiniaPlugins(): string {
    return `import { PiniaPluginContext } from 'pinia'

// Redux-style middleware converted to Pinia plugin
export function reduxCompatPlugin({ store, app }: PiniaPluginContext) {
  // Add Redux-style dispatch method
  store.dispatch = function(action: { type: string; payload?: any }) {
    const actionName = action.type.split('/').pop() // Extract action name
    if (typeof store[actionName] === 'function') {
      return store[actionName](action.payload)
    } else {
      console.warn(\`Action \${actionName} not found in store \${store.$id}\`)
    }
  }
  
  // Add Redux-style getState method
  store.getState = function() {
    return store.$state
  }
  
  // Add Redux-style subscribe method
  store.subscribe = function(listener: Function) {
    return store.$subscribe((mutation, state) => {
      listener()
    })
  }
}

// Logger plugin (similar to redux-logger)
export function loggerPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation, state) => {
    console.group(\`🔄 \${store.$id} - \${mutation.type}\`)
    console.log('Previous State:', mutation.oldValue)
    console.log('Action:', mutation)
    console.log('Next State:', state)
    console.groupEnd()
  })
}

// Persist plugin (similar to redux-persist)
export function persistPlugin({ store }: PiniaPluginContext) {
  const storageKey = \`pinia-\${store.$id}\`
  
  // Restore state from localStorage
  const savedState = localStorage.getItem(storageKey)
  if (savedState) {
    try {
      const parsedState = JSON.parse(savedState)
      store.$patch(parsedState)
    } catch (error) {
      console.warn('Failed to restore state from localStorage:', error)
    }
  }
  
  // Save state to localStorage on changes
  store.$subscribe((mutation, state) => {
    try {
      localStorage.setItem(storageKey, JSON.stringify(state))
    } catch (error) {
      console.warn('Failed to save state to localStorage:', error)
    }
  })
}
`
  }
  
  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.conversionLog.push(`✓ Generated types: ${filePath}`)
  }
  
  private generateGlobalTypes(): string {
    const storeNames = Array.from(this.reduxSlices.keys())
    
    return `// Auto-generated types from Redux migration

// Root state interface
export interface RootState {
${storeNames.map(name => `  ${name}: ${this.capitalize(name)}State`).join('\n')}
}

// Store instances
export interface StoreInstances {
${storeNames.map(name => `  ${name}: ReturnType<typeof use${this.capitalize(name)}Store>`).join('\n')}
}

// Re-export all store types
${storeNames.map(name => `export * from '../stores/${name}'`).join('\n')}
`
  }
  
  private async createTests(): Promise<void> {
    for (const [sliceName, slice] of this.reduxSlices) {
      await this.createStoreTest(sliceName, slice)
    }
  }
  
  private async createStoreTest(sliceName: string, slice: ReduxSlice): Promise<void> {
    const testContent = this.generateStoreTest(sliceName, slice)
    const filePath = path.join(this.config.targetDir, 'tests', 'stores', `${sliceName}.test.ts`)
    
    await this.ensureDirectoryExists(path.dirname(filePath))
    fs.writeFileSync(filePath, testContent)
    
    this.conversionLog.push(`✓ Created test: ${filePath}`)
  }
  
  private generateStoreTest(sliceName: string, slice: ReduxSlice): string {
    const storeHook = `use${this.capitalize(sliceName)}Store`
    
    return `import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { ${storeHook} } from '../../stores/${sliceName}'

describe('${sliceName} Store (Migrated from Redux)', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

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

${Object.keys(slice.reducers)
  .map(reducerName => `  it('should handle ${reducerName} action', () => {
    const store = ${storeHook}()
    
    // TODO: Add test for ${reducerName} (converted from Redux reducer)
    // store.${reducerName}(payload)
    // expect(store.someState).toBe(expectedValue)
  })`)
  .join('\n\n')}

${Object.keys(slice.selectors)
  .map(selectorName => `  it('should compute ${selectorName} correctly', () => {
    const store = ${storeHook}()
    
    // TODO: Add test for ${selectorName} (converted from Redux selector)
    // expect(store.${selectorName}).toBe(expectedValue)
  })`)
  .join('\n\n')}
})
`
  }
  
  private async generateConversionReport(): Promise<void> {
    const report = `# Redux to Pinia Conversion Report

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

## Conversion Summary

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

## Converted Stores

${Array.from(this.reduxSlices.entries()).map(([name, slice]) => {
  return `### ${name}
- State properties: ${Object.keys(slice.initialState).length}
- Reducers converted: ${Object.keys(slice.reducers).length}
- Selectors converted: ${Object.keys(slice.selectors).length}`
}).join('\n\n')}

## Manual Steps Required

1. Review generated stores and update business logic
2. Update component imports from Redux to Pinia
3. Replace \`useSelector\` and \`useDispatch\` with Pinia store hooks
4. Update middleware to Pinia plugins
5. Test all functionality thoroughly
6. Update documentation

## Breaking Changes

- No more reducers - direct state mutation in actions
- No more action creators - actions are functions
- No more \`useSelector\` - use computed properties
- No more \`useDispatch\` - call store methods directly
- Middleware replaced with plugins

## Next Steps

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

// Usage
export async function convertReduxToPinia(config: ConversionConfig) {
  const converter = new ReduxToPiniaConverter(config)
  await converter.convert()
}

// CLI usage
if (require.main === module) {
  const config: ConversionConfig = {
    sourceDir: './src',
    targetDir: './src',
    slicesDir: 'store/slices',
    generateTypes: true,
    createTests: true,
    preserveMiddleware: true
  }
  
  convertReduxToPinia(config).catch(console.error)
}

Redux Compatibility Layer

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

// Redux-style hooks for Pinia
export function useSelector<T, R>(selector: (state: T) => R): ComputedRef<R> {
  // This would need to be implemented based on your specific store structure
  throw new Error('useSelector needs to be implemented for your specific stores')
}

export function useDispatch() {
  // Return a dispatch function that can call store actions
  return (action: { type: string; payload?: any }) => {
    // This would need to be implemented based on your store structure
    throw new Error('useDispatch needs to be implemented for your specific stores')
  }
}

// Redux-style action creators
export function createAction<T = void>(type: string) {
  return (payload: T) => ({ type, payload })
}

// Redux-style reducer pattern for Pinia
export function createReducer<T>(initialState: T, reducers: Record<string, (state: T, action: any) => T>) {
  return (state = initialState, action: any) => {
    const reducer = reducers[action.type]
    return reducer ? reducer(state, action) : state
  }
}

// Immer-style immutable updates for Pinia
export function produce<T>(state: T, updater: (draft: T) => void): T {
  // Simple implementation - in practice, you might want to use Immer
  const draft = JSON.parse(JSON.stringify(state))
  updater(draft)
  return draft
}

// Redux DevTools integration
export function connectReduxDevTools(store: any) {
  if (typeof window !== 'undefined' && (window as any).__REDUX_DEVTOOLS_EXTENSION__) {
    const devTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__.connect({
      name: store.$id
    })
    
    // Send initial state
    devTools.init(store.$state)
    
    // Subscribe to store changes
    store.$subscribe((mutation: any, state: any) => {
      devTools.send({
        type: `${store.$id}/${mutation.type}`,
        payload: mutation.payload
      }, state)
    })
    
    // Handle time travel
    devTools.subscribe((message: any) => {
      if (message.type === 'DISPATCH' && message.state) {
        store.$patch(JSON.parse(message.state))
      }
    })
  }
}

Pattern Conversion Examples

1. Redux Slice to Pinia Store

Before (Redux Toolkit):

typescript
// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface CounterState {
  value: number
  history: number[]
}

const initialState: CounterState = {
  value: 0,
  history: []
}

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1
      state.history.push(state.value)
    },
    decrement: (state) => {
      state.value -= 1
      state.history.push(state.value)
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload
      state.history.push(state.value)
    },
    reset: (state) => {
      state.value = 0
      state.history = []
    }
  }
})

export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions
export default counterSlice.reducer

// Selectors
export const selectCount = (state: { counter: CounterState }) => state.counter.value
export const selectHistory = (state: { counter: CounterState }) => state.counter.history
export const selectLastValue = (state: { counter: CounterState }) => {
  const history = state.counter.history
  return history[history.length - 1] || 0
}

After (Pinia):

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

export const useCounterStore = defineStore('counter', () => {
  // State
  const value = ref(0)
  const history = ref<number[]>([])
  
  // Getters (converted from selectors)
  const count = computed(() => value.value)
  const lastValue = computed(() => {
    return history.value[history.value.length - 1] || 0
  })
  
  // Actions (converted from reducers)
  function increment() {
    value.value += 1
    history.value.push(value.value)
  }
  
  function decrement() {
    value.value -= 1
    history.value.push(value.value)
  }
  
  function incrementByAmount(amount: number) {
    value.value += amount
    history.value.push(value.value)
  }
  
  function reset() {
    value.value = 0
    history.value = []
  }
  
  return {
    // State
    value,
    history,
    
    // Getters
    count,
    lastValue,
    
    // Actions
    increment,
    decrement,
    incrementByAmount,
    reset
  }
})

export interface CounterState {
  value: number
  history: number[]
}

2. Async Actions (Redux Thunk to Pinia)

Before (Redux Thunk):

typescript
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from '../api/userAPI'

interface UserState {
  entities: User[]
  loading: boolean
  error: string | null
}

const initialState: UserState = {
  entities: [],
  loading: false,
  error: null
}

// Async thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async (_, { rejectWithValue }) => {
    try {
      const response = await userAPI.getUsers()
      return response.data
    } catch (error) {
      return rejectWithValue(error.message)
    }
  }
)

const userSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    clearError: (state) => {
      state.error = null
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true
        state.error = null
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.loading = false
        state.entities = action.payload
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload as string
      })
  }
})

export const { clearError } = userSlice.actions
export default userSlice.reducer

After (Pinia):

typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { userAPI } from '@/api/userAPI'

interface User {
  id: string
  name: string
  email: string
}

export const useUserStore = defineStore('users', () => {
  // State
  const entities = ref<User[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const userCount = computed(() => entities.value.length)
  const hasError = computed(() => error.value !== null)
  
  // Actions
  async function fetchUsers() {
    loading.value = true
    error.value = null
    
    try {
      const response = await userAPI.getUsers()
      entities.value = response.data
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }
  
  function clearError() {
    error.value = null
  }
  
  function addUser(user: User) {
    entities.value.push(user)
  }
  
  function removeUser(userId: string) {
    const index = entities.value.findIndex(user => user.id === userId)
    if (index > -1) {
      entities.value.splice(index, 1)
    }
  }
  
  return {
    // State
    entities,
    loading,
    error,
    
    // Getters
    userCount,
    hasError,
    
    // Actions
    fetchUsers,
    clearError,
    addUser,
    removeUser
  }
})

3. Component Migration

Before (React + Redux):

tsx
// components/Counter.tsx
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, incrementByAmount, selectCount, selectHistory } from '../store/slices/counterSlice'

const Counter: React.FC = () => {
  const count = useSelector(selectCount)
  const history = useSelector(selectHistory)
  const dispatch = useDispatch()
  
  const handleIncrement = () => {
    dispatch(increment())
  }
  
  const handleDecrement = () => {
    dispatch(decrement())
  }
  
  const handleIncrementByAmount = (amount: number) => {
    dispatch(incrementByAmount(amount))
  }
  
  return (
    <div>
      <p>Count: {count}</p>
      <p>History: {history.join(', ')}</p>
      <button onClick={handleIncrement}>+</button>
      <button onClick={handleDecrement}>-</button>
      <button onClick={() => handleIncrementByAmount(5)}>+5</button>
    </div>
  )
}

export default Counter

After (Vue + Pinia):

vue
<!-- components/Counter.vue -->
<template>
  <div>
    <p>Count: {{ counter.count }}</p>
    <p>History: {{ counter.history.join(', ') }}</p>
    <button @click="counter.increment">+</button>
    <button @click="counter.decrement">-</button>
    <button @click="() => counter.incrementByAmount(5)">+5</button>
  </div>
</template>

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

const counter = useCounterStore()
</script>

Migration Checklist

Pre-Migration

  • [ ] Audit existing Redux store structure
  • [ ] Identify async actions and middleware
  • [ ] Create backup of current implementation
  • [ ] Set up Pinia in Vue project
  • [ ] Plan migration strategy

During Migration

  • [ ] Convert Redux slices to Pinia stores
  • [ ] Transform reducers to actions
  • [ ] Convert selectors to computed properties
  • [ ] Migrate async thunks to async actions
  • [ ] Convert middleware to plugins
  • [ ] Update component usage patterns

Post-Migration

  • [ ] Update all component imports
  • [ ] Replace Redux hooks with Pinia equivalents
  • [ ] Test all functionality
  • [ ] Update documentation
  • [ ] Remove Redux 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('Redux to Pinia Migration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  describe('Counter Store Migration', () => {
    it('should maintain Redux slice functionality', () => {
      const counter = useCounterStore()
      
      // Test initial state
      expect(counter.value).toBe(0)
      expect(counter.history).toEqual([])
      
      // Test actions (formerly reducers)
      counter.increment()
      expect(counter.value).toBe(1)
      expect(counter.history).toContain(1)
      
      counter.incrementByAmount(5)
      expect(counter.value).toBe(6)
      expect(counter.history).toContain(6)
      
      // Test getters (formerly selectors)
      expect(counter.count).toBe(6)
      expect(counter.lastValue).toBe(6)
    })
  })

  describe('User Store Migration', () => {
    it('should handle async actions like Redux thunks', async () => {
      const user = useUserStore()
      
      expect(user.loading).toBe(false)
      expect(user.entities).toEqual([])
      
      // Mock API call
      const fetchPromise = user.fetchUsers()
      expect(user.loading).toBe(true)
      
      await fetchPromise
      expect(user.loading).toBe(false)
      // Additional assertions based on mocked API response
    })
  })
})

Best Practices

  1. Gradual Migration: Migrate one slice/store at a time
  2. Maintain API Compatibility: Use compatibility layer during transition
  3. Test Thoroughly: Ensure all Redux functionality is preserved
  4. Update Documentation: Keep migration progress documented
  5. Team Coordination: Ensure team understands Pinia patterns
  6. Performance Monitoring: Compare performance before and after
  7. Rollback Strategy: Have a plan to revert if needed
  8. Training: Provide Pinia training for team members

This migration guide provides a comprehensive approach to converting Redux applications to Pinia while maintaining functionality and improving developer experience.

Released under the MIT License.