Real-world experience migrating a large-scale application from Vue 2 to React. Learn about component mapping, state management migration, routing differences, and strategies to minimize downtime during the transition.
Our journey from Vue 2 to React was driven by several factors: better ecosystem support, improved TypeScript integration, and alignment with company-wide technology standards. This comprehensive guide shares our experience migrating a complex e-commerce platform serving 500K+ monthly active users.
Phase 1 (Months 1-2): Foundation
├── Setup React build system alongside Vue
├── Create shared component library
├── Migrate core utilities and services
└── Train team on React best practices
Phase 2 (Months 3-4): Core Components
├── Migrate design system components
├── Implement new routing structure
├── Setup state management with Redux Toolkit
└── Migrate authentication system
Phase 3 (Months 5-6): Feature Migration
├── Migrate user management features
├── Migrate product catalog
├── Migrate checkout flow
└── Update testing infrastructure
Phase 4 (Months 7-8): Finalization
├── Migrate remaining components
├── Performance optimization
├── Remove Vue dependencies
└── Documentation and training
<!-- UserProfile.vue -->
<template>
<div class="user-profile">
<div class="user-profile__header">
<img :src="user.avatar" :alt="user.name" />
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
<div class="user-profile__content">
<user-stats :stats="userStats" />
<user-preferences
:preferences="user.preferences"
@update="updatePreferences"
/>
</div>
</div>
</template>
<script>
import UserStats from './UserStats.vue'
import UserPreferences from './UserPreferences.vue'
export default {
name: 'UserProfile',
components: {
UserStats,
UserPreferences
},
props: {
userId: {
type: String,
required: true
}
},
data() {
return {
user: null,
userStats: null,
loading: false
}
},
async created() {
await this.fetchUser()
},
methods: {
async fetchUser() {
this.loading = true
try {
this.user = await this.$api.users.get(this.userId)
this.userStats = await this.$api.users.getStats(this.userId)
} catch (error) {
this.$toast.error('Failed to fetch user data')
} finally {
this.loading = false
}
},
async updatePreferences(preferences) {
try {
await this.$api.users.updatePreferences(this.userId, preferences)
this.user.preferences = { ...this.user.preferences, ...preferences }
this.$toast.success('Preferences updated')
} catch (error) {
this.$toast.error('Failed to update preferences')
}
}
}
}
</script>
<style scoped>
.user-profile {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.user-profile__header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
</style>
// UserProfile.jsx
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useNotification } from '../hooks/useNotification';
import { userAPI } from '../services/api';
import UserStats from './UserStats';
import UserPreferences from './UserPreferences';
import './UserProfile.css';
const UserProfile = () => {
const { userId } = useParams();
const { showSuccess, showError } = useNotification();
const [user, setUser] = useState(null);
const [userStats, setUserStats] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (userId) {
fetchUser();
}
}, [userId]);
const fetchUser = async () => {
setLoading(true);
try {
const [userData, statsData] = await Promise.all([
userAPI.get(userId),
userAPI.getStats(userId)
]);
setUser(userData);
setUserStats(statsData);
} catch (error) {
showError('Failed to fetch user data');
} finally {
setLoading(false);
}
};
const updatePreferences = async (preferences) => {
try {
await userAPI.updatePreferences(userId, preferences);
setUser(prevUser => ({
...prevUser,
preferences: { ...prevUser.preferences, ...preferences }
}));
showSuccess('Preferences updated');
} catch (error) {
showError('Failed to update preferences');
}
};
if (loading) {
return <div className="loading-spinner">Loading...</div>;
}
if (!user) {
return <div className="error-message">User not found</div>;
}
return (
<div className="user-profile">
<div className="user-profile__header">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
<div className="user-profile__content">
<UserStats stats={userStats} />
<UserPreferences
preferences={user.preferences}
onUpdate={updatePreferences}
/>
</div>
</div>
);
};
export default UserProfile;
// Conversion utilities
const templateToJSX = {
// Vue directive to React prop
'v-if': (condition) => condition && <Component />,
'v-for': (items) => items.map((item, index) => <Item key={item.id || index} />),
'v-model': (value, setValue) => ({ value, onChange: (e) => setValue(e.target.value) }),
'v-show': (condition) => ({ style: { display: condition ? 'block' : 'none' } }),
// Event handlers
'@click': (handler) => ({ onClick: handler }),
'@submit': (handler) => ({ onSubmit: handler }),
'@input': (handler) => ({ onChange: handler }),
// Class binding
':class': (classes) => ({ className: computeClasses(classes) }),
// Style binding
':style': (styles) => ({ style: styles })
};
// Helper function for class computation
const computeClasses = (classObj) => {
if (typeof classObj === 'string') return classObj;
if (Array.isArray(classObj)) return classObj.filter(Boolean).join(' ');
if (typeof classObj === 'object') {
return Object.keys(classObj)
.filter(key => classObj[key])
.join(' ');
}
return '';
};
// Vue 2 lifecycle to React hooks mapping
const lifecycleMigration = {
// beforeCreate, created -> useState initialization
created() {
// Vue 2
this.fetchData();
},
// React equivalent
useEffect(() => {
fetchData();
}, []);
// mounted -> useEffect with empty dependency
mounted() {
// Vue 2
this.setupEventListeners();
},
// React equivalent
useEffect(() => {
setupEventListeners();
return () => cleanupEventListeners();
}, []);
// beforeDestroy, destroyed -> useEffect cleanup
beforeDestroy() {
// Vue 2
this.cleanup();
},
// React equivalent (cleanup function)
useEffect(() => {
return () => cleanup();
}, []);
// watch -> useEffect with dependencies
watch: {
userId: {
handler(newVal, oldVal) {
this.fetchUserData(newVal);
},
immediate: true
}
},
// React equivalent
useEffect(() => {
if (userId) {
fetchUserData(userId);
}
}, [userId]);
};
// Vue 2 prop validation to React PropTypes/TypeScript
// Vue 2
export default {
props: {
user: {
type: Object,
required: true,
validator: (user) => user && user.id
},
size: {
type: String,
default: 'medium',
validator: (size) => ['small', 'medium', 'large'].includes(size)
}
}
}
// React with TypeScript
interface UserCardProps {
user: {
id: string;
name: string;
email: string;
};
size?: 'small' | 'medium' | 'large';
onEdit?: (user: User) => void;
}
const UserCard: React.FC<UserCardProps> = ({
user,
size = 'medium',
onEdit
}) => {
// Component implementation
};
// Event emission to callback props
// Vue 2
this.$emit('update-user', userData);
// React
onUpdateUser?.(userData);
// store/modules/user.js (Vuex)
const state = {
currentUser: null,
users: [],
loading: false,
error: null
};
const getters = {
isLoggedIn: (state) => !!state.currentUser,
userById: (state) => (id) => state.users.find(user => user.id === id),
userCount: (state) => state.users.length
};
const mutations = {
SET_CURRENT_USER(state, user) {
state.currentUser = user;
},
SET_USERS(state, users) {
state.users = users;
},
SET_LOADING(state, loading) {
state.loading = loading;
},
SET_ERROR(state, error) {
state.error = error;
},
ADD_USER(state, user) {
state.users.push(user);
},
UPDATE_USER(state, updatedUser) {
const index = state.users.findIndex(user => user.id === updatedUser.id);
if (index !== -1) {
state.users.splice(index, 1, updatedUser);
}
}
};
const actions = {
async fetchUsers({ commit }) {
commit('SET_LOADING', true);
try {
const users = await userAPI.getAll();
commit('SET_USERS', users);
} catch (error) {
commit('SET_ERROR', error.message);
} finally {
commit('SET_LOADING', false);
}
},
async createUser({ commit }, userData) {
try {
const user = await userAPI.create(userData);
commit('ADD_USER', user);
return user;
} catch (error) {
commit('SET_ERROR', error.message);
throw error;
}
}
};
export default {
namespaced: true,
state,
getters,
mutations,
actions
};
// store/slices/userSlice.js (Redux Toolkit)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { userAPI } from '../../services/api';
// Async thunks
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { rejectWithValue }) => {
try {
const users = await userAPI.getAll();
return users;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const createUser = createAsyncThunk(
'users/createUser',
async (userData, { rejectWithValue }) => {
try {
const user = await userAPI.create(userData);
return user;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const updateUser = createAsyncThunk(
'users/updateUser',
async ({ id, updates }, { rejectWithValue }) => {
try {
const user = await userAPI.update(id, updates);
return user;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Slice
const userSlice = createSlice({
name: 'users',
initialState: {
currentUser: null,
users: [],
loading: false,
error: null,
},
reducers: {
setCurrentUser: (state, action) => {
state.currentUser = action.payload;
},
clearError: (state) => {
state.error = null;
},
logout: (state) => {
state.currentUser = null;
},
},
extraReducers: (builder) => {
builder
// Fetch users
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.users = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
})
// Create user
.addCase(createUser.fulfilled, (state, action) => {
state.users.push(action.payload);
})
.addCase(createUser.rejected, (state, action) => {
state.error = action.payload;
})
// Update user
.addCase(updateUser.fulfilled, (state, action) => {
const index = state.users.findIndex(user => user.id === action.payload.id);
if (index !== -1) {
state.users[index] = action.payload;
}
});
},
});
// Selectors
export const selectUsers = (state) => state.users.users;
export const selectCurrentUser = (state) => state.users.currentUser;
export const selectUsersLoading = (state) => state.users.loading;
export const selectUsersError = (state) => state.users.error;
export const selectIsLoggedIn = (state) => !!state.users.currentUser;
export const selectUserById = (id) => (state) =>
state.users.users.find(user => user.id === id);
export const { setCurrentUser, clearError, logout } = userSlice.actions;
export default userSlice.reducer;
// Vuex store configuration
import Vue from 'vue';
import Vuex from 'vuex';
import user from './modules/user';
import products from './modules/products';
import cart from './modules/cart';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
user,
products,
cart
},
strict: process.env.NODE_ENV !== 'production'
});
// Redux Toolkit store configuration
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import productsReducer from './slices/productsSlice';
import cartReducer from './slices/cartSlice';
export const store = configureStore({
reducer: {
users: userReducer,
products: productsReducer,
cart: cartReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST'],
},
}),
devTools: process.env.NODE_ENV !== 'production',
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Custom hooks to replace Vuex mappers
import { useSelector, useDispatch } from 'react-redux';
import { useCallback } from 'react';
import {
selectUsers,
selectCurrentUser,
selectUsersLoading,
fetchUsers,
createUser,
setCurrentUser,
} from '../store/slices/userSlice';
export const useUsers = () => {
const dispatch = useDispatch();
const users = useSelector(selectUsers);
const loading = useSelector(selectUsersLoading);
const currentUser = useSelector(selectCurrentUser);
const loadUsers = useCallback(() => {
dispatch(fetchUsers());
}, [dispatch]);
const addUser = useCallback((userData) => {
return dispatch(createUser(userData));
}, [dispatch]);
const setUser = useCallback((user) => {
dispatch(setCurrentUser(user));
}, [dispatch]);
return {
users,
currentUser,
loading,
loadUsers,
addUser,
setUser,
};
};
// Usage in components
const UserList = () => {
const { users, loading, loadUsers } = useUsers();
useEffect(() => {
loadUsers();
}, [loadUsers]);
if (loading) return <div>Loading...</div>;
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
};
// router/index.js (Vue Router)
import Vue from 'vue';
import VueRouter from 'vue-router';
import Home from '../views/Home.vue';
import store from '../store';
Vue.use(VueRouter);
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/users',
name: 'Users',
component: () => import('../views/Users.vue'),
meta: { requiresAuth: true }
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('../views/UserDetail.vue'),
props: true,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { guest: true }
}
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
});
// Navigation guards
router.beforeEach((to, from, next) => {
const isLoggedIn = store.getters['user/isLoggedIn'];
if (to.matched.some(record => record.meta.requiresAuth)) {
if (!isLoggedIn) {
next({ name: 'Login' });
} else {
next();
}
} else if (to.matched.some(record => record.meta.guest)) {
if (isLoggedIn) {
next({ name: 'Home' });
} else {
next();
}
} else {
next();
}
});
export default router;
// App.jsx (React Router)
import React, { Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { useSelector } from 'react-redux';
import ProtectedRoute from './components/ProtectedRoute';
import GuestRoute from './components/GuestRoute';
import LoadingSpinner from './components/LoadingSpinner';
import { selectIsLoggedIn } from './store/slices/userSlice';
// Lazy loaded components
const Home = React.lazy(() => import('./pages/Home'));
const Users = React.lazy(() => import('./pages/Users'));
const UserDetail = React.lazy(() => import('./pages/UserDetail'));
const Login = React.lazy(() => import('./pages/Login'));
const App = () => {
return (
<Router>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={
<ProtectedRoute>
<Users />
</ProtectedRoute>
} />
<Route path="/users/:id" element={
<ProtectedRoute>
<UserDetail />
</ProtectedRoute>
} />
<Route path="/login" element={
<GuestRoute>
<Login />
</GuestRoute>
} />
</Routes>
</Suspense>
</Router>
);
};
// ProtectedRoute component
const ProtectedRoute = ({ children }) => {
const isLoggedIn = useSelector(selectIsLoggedIn);
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return children;
};
// GuestRoute component
const GuestRoute = ({ children }) => {
const isLoggedIn = useSelector(selectIsLoggedIn);
if (isLoggedIn) {
return <Navigate to="/" replace />;
}
return children;
};
export default App;
// Vue Router parameter access
// In Vue component
export default {
computed: {
userId() {
return this.$route.params.id;
},
searchQuery() {
return this.$route.query.search;
}
},
watch: {
'$route.params.id': {
handler(newId) {
this.fetchUser(newId);
},
immediate: true
}
}
};
// React Router equivalent
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
const UserDetail = () => {
const { id: userId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const searchQuery = searchParams.get('search') || '';
useEffect(() => {
if (userId) {
fetchUser(userId);
}
}, [userId]);
const updateSearch = (query) => {
setSearchParams({ search: query });
};
const goToUser = (id) => {
navigate(`/users/${id}`);
};
return (
<div>
{/* Component content */}
</div>
);
};
<template>
<div class="user-card">
<div class="user-card__header">
<img class="avatar" :src="user.avatar" />
<h3 class="name">{{ user.name }}</h3>
</div>
<div class="user-card__content">
<p class="email">{{ user.email }}</p>
</div>
</div>
</template>
<style scoped>
.user-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.user-card__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
}
.email {
margin: 0;
color: #718096;
font-size: 0.875rem;
}
</style>
// UserCard.jsx
import React from 'react';
import styles from './UserCard.module.css';
const UserCard = ({ user }) => {
return (
<div className={styles.userCard}>
<div className={styles.header}>
<img
className={styles.avatar}
src={user.avatar}
alt={user.name}
/>
<h3 className={styles.name}>{user.name}</h3>
</div>
<div className={styles.content}>
<p className={styles.email}>{user.email}</p>
</div>
</div>
);
};
export default UserCard;
/* UserCard.module.css */
.userCard {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
}
.email {
margin: 0;
color: #718096;
font-size: 0.875rem;
}
// UserCard.jsx with styled-components
import React from 'react';
import styled from 'styled-components';
const CardContainer = styled.div`
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
`;
const Header = styled.div`
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
`;
const Avatar = styled.img`
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
`;
const Name = styled.h3`
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1a202c;
`;
const Email = styled.p`
margin: 0;
color: #718096;
font-size: 0.875rem;
`;
const UserCard = ({ user }) => {
return (
<CardContainer>
<Header>
<Avatar src={user.avatar} alt={user.name} />
<Name>{user.name}</Name>
</Header>
<div>
<Email>{user.email}</Email>
</div>
</CardContainer>
);
};
export default UserCard;
// UserCard.spec.js (Vue Test Utils)
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import UserCard from '@/components/UserCard.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('UserCard.vue', () => {
let store;
let wrapper;
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
avatar: 'avatar.jpg'
};
beforeEach(() => {
store = new Vuex.Store({
modules: {
user: {
namespaced: true,
actions: {
updateUser: jest.fn()
}
}
}
});
wrapper = shallowMount(UserCard, {
localVue,
store,
propsData: {
user: mockUser
}
});
});
it('renders user information correctly', () => {
expect(wrapper.find('.name').text()).toBe('John Doe');
expect(wrapper.find('.email').text()).toBe('john@example.com');
expect(wrapper.find('.avatar').attributes('src')).toBe('avatar.jpg');
});
it('calls updateUser action when edit button is clicked', async () => {
const editButton = wrapper.find('[data-testid="edit-button"]');
await editButton.trigger('click');
expect(store._actions['user/updateUser']).toHaveBeenCalled();
});
});
// UserCard.test.jsx (React Testing Library)
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import UserCard from '../components/UserCard';
import userReducer from '../store/slices/userSlice';
const mockUser = {
id: '1',
name: 'John Doe',
email: 'john@example.com',
avatar: 'avatar.jpg'
};
const createTestStore = (preloadedState = {}) => {
return configureStore({
reducer: {
users: userReducer,
},
preloadedState,
});
};
const renderWithStore = (component, store = createTestStore()) => {
return render(
<Provider store={store}>
{component}
</Provider>
);
};
describe('UserCard', () => {
it('renders user information correctly', () => {
renderWithStore(<UserCard user={mockUser} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByAltText('John Doe')).toHaveAttribute('src', 'avatar.jpg');
});
it('calls onEdit callback when edit button is clicked', async () => {
const user = userEvent.setup();
const onEditMock = jest.fn();
renderWithStore(
<UserCard user={mockUser} onEdit={onEditMock} />
);
const editButton = screen.getByTestId('edit-button');
await user.click(editButton);
expect(onEditMock).toHaveBeenCalledWith(mockUser);
});
it('displays loading state correctly', () => {
renderWithStore(<UserCard user={mockUser} loading={true} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
// webpack.config.js optimization
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
// ... other config
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
enforce: true,
},
},
},
},
plugins: [
// Add bundle analyzer for development
process.env.ANALYZE && new BundleAnalyzerPlugin(),
].filter(Boolean),
};
Before Migration (Vue 2):
├── main.js: 1.2MB (gzipped: 350KB)
├── vendor.js: 800KB (gzipped: 250KB)
└── Total: 2MB (gzipped: 600KB)
After Migration (React):
├── main.js: 180KB (gzipped: 55KB)
├── vendor.js: 400KB (gzipped: 120KB)
├── pages/users.js: 120KB (gzipped: 35KB)
├── pages/dashboard.js: 150KB (gzipped: 45KB)
└── Total: 850KB (gzipped: 255KB)
Improvement: 57% reduction in bundle size
// Performance metrics before and after migration
const performanceMetrics = {
before: {
LCP: 3.1, // Largest Contentful Paint (seconds)
FID: 180, // First Input Delay (milliseconds)
CLS: 0.15, // Cumulative Layout Shift
TTFB: 1.2, // Time to First Byte (seconds)
FCP: 2.1 // First Contentful Paint (seconds)
},
after: {
LCP: 1.2, // 61% improvement
FID: 85, // 53% improvement
CLS: 0.05, // 67% improvement
TTFB: 0.8, // 33% improvement
FCP: 1.1 // 48% improvement
}
};
// SEO improvements
const seoMetrics = {
before: {
lighthouseScore: 67,
googlePageSpeedMobile: 45,
googlePageSpeedDesktop: 78
},
after: {
lighthouseScore: 95, // 42% improvement
googlePageSpeedMobile: 88, // 96% improvement
googlePageSpeedDesktop: 95 // 22% improvement
}
};
Topics Covered:
├── JSX syntax and differences from Vue templates
├── Component lifecycle and hooks
├── Event handling and state management
├── Props vs Vue props and events
└── React developer tools
Learning Resources:
├── Internal React workshops
├── Code review sessions
├── Pair programming exercises
└── Migration examples repository
Topics Covered:
├── Custom hooks development
├── Context API and Redux integration
├── Performance optimization techniques
├── Testing with React Testing Library
└── TypeScript integration
Practice Projects:
├── Convert existing Vue components
├── Build new features in React
├── Performance optimization exercises
└── Test coverage improvements
// Migration review checklist
const migrationChecklist = {
codeQuality: [
'Component follows React best practices',
'Proper use of hooks and lifecycle methods',
'TypeScript types are comprehensive',
'Props interface is well-defined'
],
performance: [
'Bundle size impact assessed',
'Rendering performance tested',
'Memory leaks checked',
'Core Web Vitals measured'
],
testing: [
'Unit tests migrated and passing',
'Integration tests updated',
'E2E tests still functional',
'Test coverage maintained'
],
documentation: [
'Component API documented',
'Migration notes recorded',
'Breaking changes listed',
'Usage examples provided'
]
};
Challenge: Complex Vuex modules with heavy interdependencies
// Problem: Tightly coupled Vuex modules
const userModule = {
actions: {
async fetchUser({ dispatch, commit }, userId) {
// This action depends on other modules
await dispatch('products/fetchUserProducts', userId, { root: true });
await dispatch('orders/fetchUserOrders', userId, { root: true });
// ... more dependencies
}
}
};
// Solution: Simplified Redux approach with React Query
const useUserData = (userId) => {
const user = useQuery(['user', userId], () => userAPI.get(userId));
const products = useQuery(['userProducts', userId], () => productAPI.getUserProducts(userId));
const orders = useQuery(['userOrders', userId], () => orderAPI.getUserOrders(userId));
return { user, products, orders };
};
Challenge: Global CSS conflicts during transition period
/* Problem: Vue scoped styles vs React CSS modules conflicts */
.user-card { /* Vue scoped */ }
.UserCard_user-card_abc123 { /* CSS modules */ }
/* Solution: Namespace approach during transition */
.vue-user-card { /* Temporary Vue namespace */ }
.react-user-card { /* React namespace */ }
Challenge: Vue's global event bus vs React's prop drilling
// Vue event bus usage
this.$bus.$emit('user-updated', userData);
// React solution: Context + custom hooks
const useUserEvents = () => {
const { updateUser } = useContext(UserContext);
return { updateUser };
};
// Lesson: Tree shaking is more effective with ESM
// Before: CommonJS imports
const { Button, Modal } = require('@/components');
// After: Named ESM imports
import { Button } from '@/components/Button';
import { Modal } from '@/components/Modal';
// Result: 40% reduction in bundle size
// Lesson: React requires explicit cleanup
// Vue: Automatic cleanup of watchers and listeners
watch: {
userId: 'fetchUser'
}
// React: Manual cleanup required
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal });
return () => controller.abort();
}, [userId]);
Week 1-4: 40% slower (learning curve)
Week 5-8: 80% normal speed (gaining confidence)
Week 9-12: 100% normal speed (full adoption)
Week 13-16: 120% faster (React ecosystem benefits)
Don't migrate everything at once
Don't ignore TypeScript from the start
Don't forget about accessibility
Don't overlook testing migration
The Vue 2 to React migration was a significant undertaking that ultimately resulted in improved performance, developer experience, and maintainability. Key success factors included:
The 8-month migration resulted in:
While challenging, the migration positioned our team for future growth and provided valuable lessons for similar large-scale frontend modernization projects.
The journey from Vue 2 to React taught us that with proper planning, gradual execution, and team commitment, even large-scale frontend migrations can be successful while maintaining business continuity.