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.