Understanding the Migration Landscape
Migrating from React to Vue represents a strategic shift in how you architect front-end applications. React, developed by Meta, relies heavily on JavaScript and JSX, while Vue, created by Evan You, embraces a template-based syntax with a more opinionated reactivity system. The migration process involves translating React's component model, state management, and rendering patterns into Vue's ecosystem. This guide walks you through a practical, incremental migration strategy that minimizes risk while maximizing code reuse.
Core Philosophical Differences
Before diving into code, you need to understand the fundamental paradigm shifts:
- Templates vs JSX: Vue uses HTML-based templates with directives (v-if, v-for, v-bind), while React embeds markup directly in JavaScript via JSX
- Reactivity System: Vue automatically tracks dependencies using Proxies (Vue 3) or getters/setters (Vue 2). React requires explicit state updates via useState/useReducer and re-renders on setState
- Component Definition: Vue offers Options API and Composition API. React uses functional components with hooks
- Two-way Binding: Vue provides v-model for form inputs. React enforces one-way data flow with onChange handlers
- Styling: Vue supports scoped styles natively in Single File Components. React relies on CSS-in-JS libraries or CSS Modules
Why Migration Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Organizations choose to migrate for several compelling reasons:
- Smaller Bundle Size: Vue 3's runtime is approximately 10KB gzipped versus React's ~42KB (with ReactDOM)
- Simpler Learning Curve: Vue's template syntax feels familiar to developers with HTML/CSS backgrounds, reducing onboarding time
- Performance: Vue's compile-time optimizations and automatic dependency tracking often result in faster renders out of the box
- Ecosystem Cohesion: Vue Router, Pinia, and Vite are first-party tools designed to work together seamlessly, unlike React's fragmented ecosystem
- TypeScript Integration: Vue 3's Composition API offers excellent TypeScript support without additional type annotations in templates
- Lower Boilerplate: Vue's directives and reactive system reduce the amount of code needed for common patterns
Step 1: Set Up a Parallel Vue Environment
The safest migration strategy is the "strangler fig" pattern—build new features in Vue while keeping existing React code operational. Start by installing Vue alongside React in your existing project:
npm install vue@latest
# or
yarn add vue@latest
If you're using a build system like Webpack or Vite, configure it to handle both .jsx and .vue files. For Vite, install the Vue plugin:
npm install @vitejs/plugin-vue --save-dev
Update your vite.config.js to process Vue files:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
react(),
vue()
]
})
Creating Your First Hybrid Component
You can mount Vue components inside React components using a wrapper. Here's a React component that renders a Vue instance:
// VueWrapper.jsx — React component that mounts Vue
import { useEffect, useRef } from 'react'
import { createApp } from 'vue'
export default function VueWrapper({ component, props = {} }) {
const containerRef = useRef(null)
useEffect(() => {
const app = createApp(component, props)
app.mount(containerRef.current)
return () => app.unmount()
}, [component, props])
return <div ref={containerRef} />
}
// Usage in a React component
import VueWrapper from './VueWrapper'
import VueCounter from './VueCounter.vue'
function App() {
return (
<div>
<h1>React Host</h1>
<VueWrapper component={VueCounter} props={{ initialCount: 5 }} />
</div>
)
}
Conversely, you can embed React components inside Vue using a custom integration:
<!-- ReactWrapper.vue -->
<template>
<div ref="reactContainer" />
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { createRoot } from 'react-dom/client'
import ReactCounter from './ReactCounter.jsx'
const reactContainer = ref(null)
let root = null
onMounted(() => {
root = createRoot(reactContainer.value)
root.render(<ReactCounter initialCount={5} />)
})
onUnmounted(() => {
root?.unmount()
})
</script>
Step 2: Translate Component Syntax
Once you're ready to rewrite a component entirely in Vue, start with the structural conversion. Here's a side-by-side comparison of a typical interactive component:
React Counter Component (Original)
// Counter.jsx
import { useState, useEffect } from 'react'
export default function Counter({ initialCount = 0, onUpdate }) {
const [count, setCount] = useState(initialCount)
const [isEven, setIsEven] = useState(initialCount % 2 === 0)
useEffect(() => {
setIsEven(count % 2 === 0)
onUpdate?.(count)
}, [count, onUpdate])
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialCount)
return (
<div className="counter-widget">
<h2>Count: {count}</h2>
<p className={isEven ? 'even' : 'odd'}>
The number is {isEven ? 'even' : 'odd'}
</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}
Vue Counter Component (Options API Equivalent)
<!-- CounterOptions.vue -->
<template>
<div class="counter-widget">
<h2>Count: {{ count }}</h2>
<p :class="parityClass">
The number is {{ isEven ? 'even' : 'odd' }}
</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</div>
</template>
<script>
export default {
props: {
initialCount: { type: Number, default: 0 }
},
emits: ['update'],
data() {
return {
count: this.initialCount
}
},
computed: {
isEven() {
return this.count % 2 === 0
},
parityClass() {
return this.isEven ? 'even' : 'odd'
}
},
watch: {
count(newValue) {
this.$emit('update', newValue)
}
},
methods: {
increment() { this.count++ },
decrement() { this.count-- },
reset() { this.count = this.initialCount }
}
}
</script>
<style scoped>
.even { color: green; }
.odd { color: orange; }
</style>
Vue Counter (Composition API with <script setup>)
<!-- CounterComposition.vue -->
<template>
<div class="counter-widget">
<h2>Count: {{ count }}</h2>
<p :class="isEven ? 'even' : 'odd'">
The number is {{ isEven ? 'even' : 'odd' }}
</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
initialCount: { type: Number, default: 0 }
})
const emit = defineEmits(['update'])
const count = ref(props.initialCount)
const isEven = computed(() => count.value % 2 === 0)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => { count.value = props.initialCount }
watch(count, (newVal) => emit('update', newVal))
</script>
<style scoped>
.even { color: green; }
.odd { color: orange; }
</style>
Step 3: Convert React Hooks to Vue Composables
React custom hooks translate naturally into Vue composables. Both are functions that encapsulate reactive logic. The key difference: Vue composables use ref() and reactive() instead of useState(), and they don't require dependency arrays for effects.
React Custom Hook
// useWindowSize.js — React custom hook
import { useState, useEffect } from 'react'
export function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}
Vue Composable Equivalent
// useWindowSize.js — Vue composable
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}
// Usage in a Vue component
<script setup>
import { useWindowSize } from './useWindowSize'
const { width, height } = useWindowSize()
</script>
Handling useEffect with Dependencies
React's useEffect with dependency arrays maps to Vue's watch or watchEffect. Here's a data-fetching example:
// React: Fetching data when ID changes
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data)
setLoading(false)
})
}, [userId]) // Re-run when userId changes
if (loading) return <div>Loading...</div>
return <div>{user.name}</div>
}
// Vue equivalent with watch
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({ userId: Number })
const user = ref(null)
const loading = ref(true)
watch(
() => props.userId,
async (newId) => {
loading.value = true
const res = await fetch(`/api/users/${newId}`)
user.value = await res.json()
loading.value = false
},
{ immediate: true } // Run on mount, like useEffect initial run
)
</script>
For simpler cases where you don't need to track specific sources, watchEffect automatically collects dependencies:
// Vue: Automatic dependency tracking with watchEffect
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
// Automatically re-runs when count.value changes
doubled.value = count.value * 2
console.log(`Count is now ${count.value}`)
})
</script>
Step 4: Migrate State Management
React applications commonly use Redux, Zustand, or Context API. Vue's ecosystem offers Pinia (the official state management library) and provide/inject for simpler cases.
React Context API → Vue provide/inject
// React: Theme context
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light')
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// Vue equivalent using provide/inject
<script setup>
// ThemeProvider.vue
import { ref, provide } from 'vue'
const theme = ref('light')
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('theme', {
theme,
toggle
})
</script>
<!-- Any descendant component -->
<script setup>
import { inject } from 'vue'
const { theme, toggle } = inject('theme')
</script>
Redux Slice → Pinia Store
// React Redux: Todo slice (using Redux Toolkit)
// todosSlice.js
import { createSlice } from '@reduxjs/toolkit'
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all'
},
reducers: {
addTodo(state, action) {
state.items.push({
id: Date.now(),
text: action.payload,
completed: false
})
},
toggleTodo(state, action) {
const todo = state.items.find(t => t.id === action.payload)
if (todo) todo.completed = !todo.completed
},
setFilter(state, action) {
state.filter = action.payload
}
}
})
export const { addTodo, toggleTodo, setFilter } = todosSlice.actions
export default todosSlice.reducer
// Vue Pinia: Equivalent store
// stores/todos.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useTodosStore = defineStore('todos', () => {
const items = ref([])
const filter = ref('all')
const filteredTodos = computed(() => {
if (filter.value === 'active') return items.value.filter(t => !t.completed)
if (filter.value === 'completed') return items.value.filter(t => t.completed)
return items.value
})
function addTodo(text) {
items.value.push({
id: Date.now(),
text,
completed: false
})
}
function toggleTodo(id) {
const todo = items.value.find(t => t.id === id)
if (todo) todo.completed = !todo.completed
}
function setFilter(newFilter) {
filter.value = newFilter
}
return { items, filter, filteredTodos, addTodo, toggleTodo, setFilter }
})
// Usage in a Vue component
<script setup>
import { useTodosStore } from '@/stores/todos'
const store = useTodosStore()
// Access: store.items, store.filteredTodos
// Actions: store.addTodo('Buy milk')
</script>
Step 5: Convert Routing Configuration
React Router and Vue Router share similar concepts but differ in syntax. Vue Router uses a declarative configuration object and provides router-link components with scoped slot capabilities.
React Router v6 Configuration
// App.jsx — React Router setup
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import Home from './Home'
import About from './About'
import UserProfile from './UserProfile'
export default function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/user/42">User 42</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
</Routes>
</BrowserRouter>
)
}
Vue Router Equivalent
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import UserProfile from '@/views/UserProfile.vue'
const routes = [
{ path: '/', name: 'Home', component: Home },
{ path: '/about', name: 'About', component: About },
{
path: '/user/:id',
name: 'UserProfile',
component: UserProfile,
props: true // Pass route params as props
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
// App.vue — Main layout
<template>
<nav>
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
<router-link :to="{ name: 'UserProfile', params: { id: 42 }}">
User 42
</router-link>
</nav>
<router-view /> <!-- Renders matched component -->
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
// Programmatic navigation
const router = useRouter()
const route = useRoute()
function goToUser(id) {
router.push({ name: 'UserProfile', params: { id } })
}
// Access current route params
console.log(route.params.id)
</script>
Step 6: Translate Form Handling and Validation
Vue's v-model directive simplifies two-way binding significantly. For form validation, Vue ecosystem offers Vuelidate and VeeValidate as alternatives to React's Formik or React Hook Form.
React Form with Controlled Inputs
// LoginForm.jsx — React controlled form
import { useState } from 'react'
export default function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errors, setErrors] = useState({})
function validate() {
const newErrors = {}
if (!email.includes('@')) newErrors.email = 'Invalid email'
if (password.length < 6) newErrors.password = 'Too short'
return newErrors
}
function handleSubmit(e) {
e.preventDefault()
const errs = validate()
if (Object.keys(errs).length === 0) {
onSubmit({ email, password })
setEmail('')
setPassword('')
} else {
setErrors(errs)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => { setEmail(e.target.value); setErrors({}) }}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
type="password"
value={password}
onChange={e => { setPassword(e.target.value); setErrors({}) }}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Log In</button>
</form>
)
}
Vue Form with v-model
<!-- LoginForm.vue -->
<template>
<form @submit.prevent="handleSubmit">
<input
type="email"
v-model="email"
placeholder="Email"
@input="clearError('email')"
/>
<span v-if="errors.email" class="error">{{ errors.email }}</span>
<input
type="password"
v-model="password"
placeholder="Password"
@input="clearError('password')"
/>
<span v-if="errors.password" class="error">{{ errors.password }}</span>
<button type="submit">Log In</button>
</form>
</template>
<script setup>
import { ref, reactive } from 'vue'
const emit = defineEmits(['submit'])
const email = ref('')
const password = ref('')
const errors = reactive({})
function validate() {
const newErrors = {}
if (!email.value.includes('@')) newErrors.email = 'Invalid email'
if (password.value.length < 6) newErrors.password = 'Too short'
return newErrors
}
function clearError(field) {
delete errors[field]
}
function handleSubmit() {
const errs = validate()
if (Object.keys(errs).length === 0) {
emit('submit', { email: email.value, password: password.value })
email.value = ''
password.value = ''
} else {
Object.assign(errors, errs)
}
}
</script>
Step 7: Adapt Testing Strategy
React Testing Library and Jest work well with Vue components when combined with Vue Test Utils. The testing philosophy shifts from "render, act, assert" in React to "mount, interact, assert" in Vue.
// React: Testing the Counter component
// Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react'
import Counter from './Counter'
test('increments counter on button click', () => {
render(<Counter initialCount={0} />)
const button = screen.getByText('+')
fireEvent.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
// Vue: Equivalent test with Vue Test Utils
// Counter.test.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('increments counter on button click', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 0 }
})
// Find button by text and click it
await wrapper.find('button', { name: '+' }).trigger('click')
// Assert the updated text
expect(wrapper.find('h2').text()).toBe('Count: 1')
})
Step 8: Incremental Migration Strategy for Large Codebases
For production applications, a full rewrite is rarely feasible. Use this phased approach:
- Phase 1 - Coexistence: Install Vue alongside React. Build new features as Vue components embedded via wrapper components
- Phase 2 - Leaf Components First: Migrate the simplest, most isolated components (buttons, inputs, cards) that have no children
- Phase 3 - Shared State: Move shared state logic into framework-agnostic stores or use event buses. Consider using Pinia stores accessible from React via a thin wrapper
- Phase 4 - Page-by-Page: Convert entire routes/pages to Vue, using Vue Router for new pages while React Router handles legacy routes
- Phase 5 - Remove React: Once all components are migrated, remove React dependencies and consolidate build configuration
Framework-Agnostic State Sharing Example
// sharedState.js — Framework-agnostic reactive state
// Works with both React and Vue via a subscription model
class SharedStore {
constructor(initialState) {
this.state = initialState
this.listeners = new Set()
}
getState() {
return this.state
}
setState(partial) {
this.state = { ...this.state, ...partial }
this.listeners.forEach(fn => fn(this.state))
}
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
}
export const authStore = new SharedStore({ user: null, token: null })
// React hook to use shared store
import { useState, useEffect } from 'react'
import { authStore } from './sharedState'
export function useSharedAuth() {
const [state, setState] = useState(authStore.getState())
useEffect(() => authStore.subscribe(setState), [])
return state
}
// Vue composable to use shared store
import { ref, onMounted, onUnmounted } from 'vue'
import { authStore } from './sharedState'
export function useSharedAuth() {
const state = ref(authStore.getState())
let unsubscribe
onMounted(() => {
unsubscribe = authStore.subscribe(newState => {
state.value = newState
})
})
onUnmounted(() => unsubscribe?.())
return state
}
Best Practices for a Smooth Migration
- Establish Feature Flags: Use a flag system to toggle between React and Vue implementations per component, allowing gradual rollout and easy rollback
- Maintain Visual Consistency: Extract CSS into shared stylesheets or use design tokens so both React and Vue components look identical during the transition
- Create a Migration Dashboard: Track which components have been migrated, their test coverage, and performance metrics before and after conversion
- Automate Where Possible: Write codemods to handle mechanical translations like
useStatetoref()oronClickto@click. While not perfect, they reduce manual effort significantly - Keep Commit History Clean: Migrate one component per commit with clear messages like "Migrate UserCard from React to Vue"
- Parallel Test Suites: Run both React and Vue test suites in CI until migration is complete. Use snapshot testing to catch visual regressions
- Document the Mapping: Maintain a living document mapping React patterns to their Vue equivalents for team reference
Common Pitfalls to Avoid
- Don't Migrate Everything at Once: A big-bang rewrite almost always fails. Migrate incrementally
- Don't Mix Patterns in One Component: Either write a component in React or Vue, not a hybrid of both
- Watch Out for Async Differences: Vue's
nextTick()is similar to React'sflushSync()but not identical. Understand Vue's async update batching - Be Careful with Direct DOM Manipulation: Vue's template refs (
ref="el") are not interchangeable with React'suseRef. Always use Vue'sref()andv-forfor list rendering - Don't Neglect TypeScript: If your React codebase uses TypeScript, set up Vue with
vue-tscand maintain type safety throughout migration
Performance Considerations Post-Migration
After migration, leverage Vue-specific optimizations:
<!-- Use shallowRef for large objects that don't need deep reactivity -->
<script setup>
import { shallowRef } from 'vue'
// Only the .value reference is reactive, not nested properties
const config = shallowRef({
theme: { colors: { primary: '#333' } },
layout: { sidebar: { width: 250 } }
})
// To trigger an update, replace the entire object
config.value = { ...config.value, theme: { colors: { primary: '#000' } } }
</script>
<!-- Use v-memo to skip re-renders of static content -->
<template>
<div v-for="item in longList" :key="item.id" v-memo="[item.selected]">
<!-- Only re-renders when item.selected changes -->
<span>{{ item.name }}</span>
<span>{{ item.description }}</span>
</div>
</template>
Conclusion
Migrating from React to Vue is a strategic undertaking that, when executed incrementally, yields significant benefits in bundle size, developer experience, and out-of-the-box performance. The key to success lies in understanding the conceptual mappings—JSX to templates, hooks to composables, Redux to Pinia, and React Router to Vue Router—while maintaining a coexistence strategy that keeps the application fully functional throughout the transition. By following the strangler fig pattern, converting leaf components first, sharing state through framework-agnostic stores, and leveraging Vue's compile-time optimizations, teams can complete a migration without business disruption. The resulting codebase will be more concise, easier to onboard new developers onto, and benefit from Vue's cohesive first-party ecosystem. Remember that the goal is not merely to replicate React patterns in Vue syntax, but to embrace Vue's strengths: declarative templates, automatic reactivity, scoped styling, and the powerful Composition API that brings the best of both worlds together.