After leading several Vue 2 to Vue 3 migrations across production applications serving thousands of daily users, I’ve learned valuable lessons about the process. This post shares practical insights, gotchas, and strategies that worked in real-world scenarios.
Why Migrate to Vue 3?
Before diving into the how, let’s understand the why:
Performance Improvements
- Smaller bundle size - 41% lighter than Vue 2
- Faster rendering - Proxy-based reactivity system
- Better TypeScript support - Built with TypeScript from the ground up
- Composition API - Better code organization and reusability
Developer Experience
- Better IDE support - Improved IntelliSense and type inference
- Fragment support - Multiple root nodes in templates
- Teleport - Easy portal/modal implementation
- Suspense - Built-in async component handling
Migration Strategy
1. Assessment Phase
First, audit your codebase:
# Install Vue 3 migration helper
npm install -g @vue/compat
# Analyze your project
vue-migration-helper .
Key questions to answer:
- How many components do we have?
- What dependencies are Vue 2 only?
- Are we using deprecated patterns?
- What’s our test coverage?
2. Preparation
Update dependencies first:
{
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.0.0",
"pinia": "^2.1.0" // Replaces Vuex
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}
Note: Vuex 4 exists for Vue 3, but Pinia is now the official recommendation.
3. Use Migration Build
Vue 3’s compatibility build lets you migrate incrementally:
import { createApp } from 'vue'
import { configureCompat } from '@vue/compat'
configureCompat({
MODE: 3,
FEATURE_A: false, // Disable specific Vue 2 behaviors
FEATURE_B: 'suppress-warning'
})
createApp(App).mount('#app')
Breaking Changes & Solutions
1. Global API Changes
Vue 2:
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
Vue.use(VueRouter)
new Vue({
render: h => h(App)
}).$mount('#app')
Vue 3:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
Lesson: This affects plugins and third-party libraries significantly. Update all Vue.use() calls.
2. V-Model Changes
Vue 2:
<template>
<CustomInput v-model="searchText" />
</template>
<script>
export default {
props: ['value'],
methods: {
updateValue(val) {
this.$emit('input', val)
}
}
}
</script>
Vue 3:
<template>
<CustomInput v-model="searchText" />
</template>
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function updateValue(val) {
emit('update:modelValue', val)
}
</script>
Lesson: value prop is now modelValue, and input event is now update:modelValue.
3. Filters Removed
Vue 2:
<template>
<p>{{ price | currency }}</p>
</template>
<script>
export default {
filters: {
currency(value) {
return `$${value.toFixed(2)}`
}
}
}
</script>
Vue 3 (use methods or computed):
<template>
<p>{{ formatCurrency(price) }}</p>
</template>
<script setup>
const formatCurrency = (value) => `$${value.toFixed(2)}`
</script>
Lesson: Create a composable for reusable formatting functions:
// composables/useFormatters.js
export function useFormatters() {
const formatCurrency = (value) => `$${value.toFixed(2)}`
const formatDate = (date) => new Date(date).toLocaleDateString()
return { formatCurrency, formatDate }
}
4. $listeners Removed
Vue 2:
<template>
<div>
<ChildComponent v-bind="$attrs" v-on="$listeners" />
</div>
</template>
Vue 3:
<template>
<div>
<ChildComponent v-bind="$attrs" />
</div>
</template>
<script setup>
// $attrs now includes event listeners
defineOptions({
inheritAttrs: false
})
</script>
Lesson: $attrs now contains both attributes and event listeners.
Composition API Migration
Options API to Composition API
Vue 2 (Options API):
<script>
export default {
data() {
return {
count: 0,
message: 'Hello'
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
console.log('Component mounted')
}
}
</script>
Vue 3 (Composition API with <script setup>):
<script setup>
import { ref, computed, onMounted } from 'vue'
const count = ref(0)
const message = ref('Hello')
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
onMounted(() => {
console.log('Component mounted')
})
</script>
Lesson: <script setup> provides better TypeScript inference and less boilerplate.
Creating Composables
Extract reusable logic into composables:
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
Usage:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement } = useCounter(10)
</script>
State Management: Vuex to Pinia
Vuex (Vue 2/3)
// store/index.js
import { createStore } from 'vuex'
export default createStore({
state: {
user: null
},
mutations: {
SET_USER(state, user) {
state.user = user
}
},
actions: {
async login({ commit }, credentials) {
const user = await api.login(credentials)
commit('SET_USER', user)
}
}
})
Pinia (Vue 3 Recommended)
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const user = ref(null)
async function login(credentials) {
user.value = await api.login(credentials)
}
return { user, login }
})
Benefits:
- ✅ No mutations required
- ✅ Full TypeScript support
- ✅ Better devtools integration
- ✅ Modular by design
Router Migration (Vue Router 3 → 4)
Route Definition Changes
Vue Router 3:
const routes = [
{
path: '/user/:id',
component: User,
children: [
{ path: 'profile', component: Profile }
]
}
]
const router = new VueRouter({
mode: 'history',
routes
})
Vue Router 4:
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/user/:id',
component: User,
children: [
{ path: 'profile', component: Profile }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
Navigation Guards
The API is mostly the same, but return values changed:
Vue Router 4:
router.beforeEach((to, from) => {
if (!isAuthenticated && to.meta.requiresAuth) {
return { name: 'login' } // Return route location instead of next()
}
// Return nothing to continue
})
Testing Strategy
Unit Tests
Update test setup for Vue 3:
// Vue 2 (Vue Test Utils 1.x)
import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(Component, {
propsData: { msg: 'Hello' }
})
// Vue 3 (Vue Test Utils 2.x)
import { shallowMount } from '@vue/test-utils'
const wrapper = shallowMount(Component, {
props: { msg: 'Hello' } // Changed from propsData
})
E2E Tests
Minimal changes needed for Cypress/Playwright tests since they test the DOM, not Vue internals.
Performance Optimization
Tree Shaking
Vue 3 is designed for better tree-shaking:
Good:
import { ref, computed } from 'vue' // Only import what you need
Bad:
import * as Vue from 'vue' // Imports everything
Lazy Loading
Both Vue 2 and 3 support lazy loading, but Vue 3 has better <Suspense>:
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
</script>
Common Gotchas
1. Reactive Arrays
Issue:
const list = reactive([1, 2, 3])
list = [4, 5, 6] // ❌ Breaks reactivity!
Solution:
const list = ref([1, 2, 3])
list.value = [4, 5, 6] // ✅ Maintains reactivity
2. Template Refs
Vue 2:
<template>
<input ref="inputRef" />
</template>
<script>
export default {
mounted() {
this.$refs.inputRef.focus()
}
}
</script>
Vue 3:
<template>
<input ref="inputRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const inputRef = ref(null)
onMounted(() => {
inputRef.value.focus()
})
</script>
3. Provide/Inject with TypeScript
// types.ts
import type { InjectionKey, Ref } from 'vue'
export interface User {
id: string
name: string
}
export const userKey: InjectionKey<Ref<User | null>> = Symbol('user')
// Parent.vue
import { provide, ref } from 'vue'
import { userKey } from './types'
const user = ref<User | null>(null)
provide(userKey, user)
// Child.vue
import { inject } from 'vue'
import { userKey } from './types'
const user = inject(userKey) // Fully typed!
Migration Checklist
- [ ] Audit codebase and dependencies
- [ ] Update to latest Vue 2.7 (includes Composition API backport)
- [ ] Write tests for critical functionality
- [ ] Install migration build (
@vue/compat) - [ ] Update build tools (Vite recommended)
- [ ] Migrate global API (
createApp) - [ ] Update v-model usage
- [ ] Replace filters with methods/computed
- [ ] Migrate state management to Pinia
- [ ] Update Vue Router to v4
- [ ] Update test suite
- [ ] Test thoroughly in staging
- [ ] Monitor performance in production
- [ ] Remove
@vue/compatwhen ready
Conclusion
Migrating from Vue 2 to Vue 3 is a significant undertaking, but the benefits are worth it:
- ✅ Better performance - Smaller bundle, faster rendering
- ✅ Improved DX - Better TypeScript support, composition API
- ✅ Modern features - Teleport, Suspense, multiple root nodes
- ✅ Future-proof - Active development and ecosystem
Key takeaways from production migrations:
- Use the migration build for gradual migration
- Start with leaf components, work up to root
- Migrate state management early (Pinia is great!)
- Invest in comprehensive tests
- Monitor performance metrics before/after
The Vue 3 ecosystem is mature and production-ready. If you’re still on Vue 2, now is the time to plan your migration!
Questions about Vue 3 migration? Let’s connect!