Vue 3 Migration Guide: Upgrading from Vue 2 to Vue 3 Alpha
Vue 3 alpha was released. I wanted to try the new Composition API. Migrated a small internal app. Hit many breaking changes. Learned a lot.
The app is faster, code is cleaner, but migration took effort. Here’s what I learned about Vue 3.
Table of Contents
What’s New in Vue 3
Major changes:
- Composition API: New way to organize code
- Performance: Faster rendering, smaller bundle
- TypeScript: Better TS support
- Multiple root nodes: Fragments
- Teleport: Render outside component
- Suspense: Async component loading
Composition API
Vue 2 (Options API):
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
console.log('Mounted')
}
}
</script>
Vue 3 (Composition API):
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script>
import { ref, onMounted } from 'vue'
export default {
setup() {
const count = ref(0)
function increment() {
count.value++
}
onMounted(() => {
console.log('Mounted')
})
return {
count,
increment
}
}
}
</script>
Reactive References
ref for primitives:
import { ref } from 'vue'
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
reactive for objects:
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'Alice'
})
console.log(state.count) // 0 (no .value needed!)
state.count++
Computed Properties
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
console.log(doubled.value) // 0
count.value = 5
console.log(doubled.value) // 10
Watchers
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`Count changed from ${oldValue} to ${newValue}`)
})
count.value++ // Logs: "Count changed from 0 to 1"
Watch multiple sources:
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
console.log('Something changed')
})
Lifecycle Hooks
Vue 2 → Vue 3 mapping:
| Vue 2 | Vue 3 |
|---|---|
| beforeCreate | setup() |
| created | setup() |
| beforeMount | onBeforeMount |
| mounted | onMounted |
| beforeUpdate | onBeforeUpdate |
| updated | onUpdated |
| beforeDestroy | onBeforeUnmount |
| destroyed | onUnmounted |
Example:
import { onMounted, onUnmounted } from 'vue'
export default {
setup() {
onMounted(() => {
console.log('Component mounted')
})
onUnmounted(() => {
console.log('Component unmounted')
})
}
}
Breaking Changes
1. Global API changes:
// Vue 2
import Vue from 'vue'
Vue.component('MyComponent', {})
Vue.directive('focus', {})
// Vue 3
import { createApp } from 'vue'
const app = createApp({})
app.component('MyComponent', {})
app.directive('focus', {})
2. v-model changes:
<!-- Vue 2 -->
<MyComponent v-model="value" />
<!-- Equivalent to: -->
<MyComponent :value="value" @input="value = $event" />
<!-- Vue 3 -->
<MyComponent v-model="value" />
<!-- Equivalent to: -->
<MyComponent :modelValue="value" @update:modelValue="value = $event" />
3. Filters removed:
<!-- Vue 2 -->
{{ message | capitalize }}
<!-- Vue 3 (use method or computed) -->
{{ capitalize(message) }}
4. $listeners removed:
<!-- Vue 2 -->
<ChildComponent v-on="$listeners" />
<!-- Vue 3 (merged into $attrs) -->
<ChildComponent v-bind="$attrs" />
Fragments (Multiple Root Nodes)
Vue 2 (requires single root):
<template>
<div>
<h1>Title</h1>
<p>Content</p>
</div>
</template>
Vue 3 (multiple roots allowed):
<template>
<h1>Title</h1>
<p>Content</p>
</template>
Teleport
Render component elsewhere in DOM:
<template>
<div>
<button @click="showModal = true">Open Modal</button>
<teleport to="body">
<div v-if="showModal" class="modal">
<p>Modal content</p>
<button @click="showModal = false">Close</button>
</div>
</teleport>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup() {
const showModal = ref(false)
return { showModal }
}
}
</script>
Modal renders at <body> level, not inside component!
Suspense
Handle async components:
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
<script>
import { defineAsyncComponent } from 'vue'
export default {
components: {
AsyncComponent: defineAsyncComponent(() =>
import('./AsyncComponent.vue')
)
}
}
</script>
Composables (Reusable Logic)
Extract reusable logic:
// useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function decrement() {
count.value--
}
return {
count,
increment,
decrement
}
}
Use in component:
<script>
import { useCounter } from './useCounter'
export default {
setup() {
const { count, increment, decrement } = useCounter(10)
return {
count,
increment,
decrement
}
}
}
</script>
TypeScript Support
Much better in Vue 3:
import { ref, computed, Ref } from 'vue'
interface User {
id: number
name: string
}
export default {
setup() {
const user: Ref<User | null> = ref(null)
const userName = computed(() => user.value?.name ?? 'Guest')
function setUser(newUser: User) {
user.value = newUser
}
return {
user,
userName,
setUser
}
}
}
Migration Strategy
Phase 1: Install Vue 3
npm install vue@next
Phase 2: Update build config
// vue.config.js
module.exports = {
chainWebpack: config => {
config.resolve.alias.set('vue', '@vue/compat')
}
}
Phase 3: Fix breaking changes
- Update global API usage
- Fix v-model props
- Remove filters
- Update $listeners to $attrs
Phase 4: Gradually adopt Composition API
Start with new components, migrate old ones over time.
Performance Improvements
Benchmark results:
| Metric | Vue 2 | Vue 3 | Improvement |
|---|---|---|---|
| Initial render | 100ms | 55ms | 45% faster |
| Update | 50ms | 25ms | 50% faster |
| Bundle size | 63KB | 41KB | 35% smaller |
| Memory | 10MB | 7MB | 30% less |
Real Migration Example
Before (Vue 2):
<template>
<div>
<input v-model="searchQuery" placeholder="Search users">
<ul>
<li v-for="user in filteredUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
searchQuery: '',
users: []
}
},
computed: {
filteredUsers() {
return this.users.filter(u =>
u.name.toLowerCase().includes(this.searchQuery.toLowerCase())
)
}
},
mounted() {
this.fetchUsers()
},
methods: {
async fetchUsers() {
const response = await fetch('/api/users')
this.users = await response.json()
}
}
}
</script>
After (Vue 3):
<template>
<div>
<input v-model="searchQuery" placeholder="Search users">
<ul>
<li v-for="user in filteredUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue'
export default {
setup() {
const searchQuery = ref('')
const users = ref([])
const filteredUsers = computed(() =>
users.value.filter(u =>
u.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
)
async function fetchUsers() {
const response = await fetch('/api/users')
users.value = await response.json()
}
onMounted(() => {
fetchUsers()
})
return {
searchQuery,
filteredUsers
}
}
}
</script>
Gotchas
1. Remember .value:
const count = ref(0)
console.log(count) // ❌ Wrong: { value: 0 }
console.log(count.value) // ✅ Correct: 0
2. Reactive unwrapping in template:
<template>
<!-- No .value needed in template -->
<p>{{ count }}</p>
</template>
<script>
const count = ref(0)
// But .value needed in setup()
count.value++
</script>
3. Destructuring reactive:
// ❌ Loses reactivity
const { count } = reactive({ count: 0 })
// ✅ Use toRefs
const state = reactive({ count: 0 })
const { count } = toRefs(state)
Results
Pros:
- Better code organization
- Improved TypeScript support
- Better performance
- Smaller bundle size
- Reusable composables
Cons:
- Learning curve
- Breaking changes
- Ecosystem not ready (alpha)
- Some libraries incompatible
Lessons Learned
- Don’t rush migration - Vue 3 is alpha
- Start with new components - Use Composition API
- Test thoroughly - Breaking changes are subtle
- Wait for ecosystem - Many libraries not ready
- Read migration guide - Official docs are good
Conclusion
Vue 3 brings significant improvements but requires careful migration. The Composition API is powerful for organizing complex logic.
Key takeaways:
- Composition API for better code organization
- Performance improvements are real
- Breaking changes require attention
- TypeScript support is much better
- Wait for stable release for production
Vue 3 is promising. But for production, wait for stable release and ecosystem maturity.