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
Aspect | Redux | Pinia |
---|---|---|
Store Structure | Single store with reducers | Multiple stores |
State Updates | Immutable with reducers | Direct mutation or actions |
Actions | Plain objects with types | Functions with parameters |
Middleware | Complex middleware chain | Simple plugins |
Selectors | Manual memoization | Computed properties |
Side Effects | Redux-Saga/Thunk | Async actions |
TypeScript | Complex type definitions | Built-in TypeScript support |
DevTools | Redux DevTools | Vue DevTools |
Boilerplate | High boilerplate | Minimal 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
- Gradual Migration: Migrate one slice/store at a time
- Maintain API Compatibility: Use compatibility layer during transition
- Test Thoroughly: Ensure all Redux functionality is preserved
- Update Documentation: Keep migration progress documented
- Team Coordination: Ensure team understands Pinia patterns
- Performance Monitoring: Compare performance before and after
- Rollback Strategy: Have a plan to revert if needed
- 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.