← Back to DevBytes

Migrating from React to Vue: Step-by-Step Guide

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:

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:

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:

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

Common Pitfalls to Avoid

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles