Migrating to Vue 3 Composition API: Early Adopter Experience
Vue 3’s Composition API RFC was released a few weeks ago, and it’s been controversial. Some people love it, others think it’s too React-like and abandons Vue’s simplicity.
I decided to try it on a real project. I migrated one of our internal dashboards (about 15,000 lines of Vue code) to use the Composition API via the @vue/composition-api plugin. Here’s what I learned.
Table of Contents
Why I Tried It
Our Vue 2 codebase has some pain points:
- Logic reuse is awkward - Mixins cause naming conflicts and unclear dependencies
- Large components are hard to navigate - Related logic is scattered across options
- TypeScript support is mediocre - Type inference doesn’t work well with
this
The Composition API promises to solve all of these. I was skeptical but curious.
Setting Up the Plugin
Since Vue 3 isn’t released yet, I used the official plugin:
npm install @vue/composition-api
Then register it:
import Vue from 'vue'
import VueCompositionApi from '@vue/composition-api'
Vue.use(VueCompositionApi)
Now I can use the Composition API in Vue 2 components. The plugin is surprisingly stable - I haven’t hit any major bugs.
Basic Migration Pattern
Here’s a typical Vue 2 component:
export default {
data() {
return {
count: 0,
message: 'Hello'
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
console.log('Component mounted')
}
}
With Composition API:
import { ref, computed, onMounted } from '@vue/composition-api'
export default {
setup() {
const count = ref(0)
const message = ref('Hello')
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
onMounted(() => {
console.log('Component mounted')
})
return {
count,
message,
doubleCount,
increment
}
}
}
The setup() function runs before the component is created. Everything you return is exposed to the template.
Reactive vs Ref
This confused me at first. There are two ways to create reactive state:
import { ref, reactive } from '@vue/composition-api'
// ref: for primitives
const count = ref(0)
console.log(count.value) // Access via .value
count.value++
// reactive: for objects
const state = reactive({
count: 0,
message: 'Hello'
})
console.log(state.count) // Direct access
state.count++
I initially used reactive for everything, but ran into issues:
// This doesn't work!
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // Loses reactivity!
// This works
const state = reactive({ count: 0 })
state.count = 1
You can’t reassign reactive objects. For that, use ref:
const state = ref({ count: 0 })
state.value = { count: 1 } // Works fine
My rule of thumb:
- Use
reffor primitives and when you need to reassign - Use
reactivefor objects that won’t be reassigned
Extracting Reusable Logic
This is where the Composition API shines. Here’s a mixin we had for fetching user data:
// Old mixin approach
export default {
data() {
return {
user: null,
loading: false,
error: null
}
},
methods: {
async fetchUser(id) {
this.loading = true
try {
this.user = await api.getUser(id)
} catch (e) {
this.error = e
} finally {
this.loading = false
}
}
}
}
Problems with this mixin:
- Name conflicts if component has its own
userproperty - Hard to track where
usercomes from - Can’t use multiple times for different resources
With Composition API, I created a composable function:
// composables/useResource.js
import { ref } from '@vue/composition-api'
export function useResource(fetcher) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const fetch = async (...args) => {
loading.value = true
error.value = null
try {
data.value = await fetcher(...args)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
fetch
}
}
Now I can use it in components:
import { useResource } from '@/composables/useResource'
import api from '@/api'
export default {
setup() {
const {
data: user,
loading: userLoading,
error: userError,
fetch: fetchUser
} = useResource(api.getUser)
const {
data: posts,
loading: postsLoading,
fetch: fetchPosts
} = useResource(api.getPosts)
onMounted(() => {
fetchUser(123)
fetchPosts()
})
return {
user,
userLoading,
userError,
posts,
postsLoading
}
}
}
This is so much better than mixins:
- No naming conflicts (I can rename on import)
- Clear where data comes from
- Can use multiple times
- Easy to test in isolation
Organizing Large Components
One of my components had 500+ lines with data, computed, methods, and lifecycle hooks all mixed together. With Composition API, I organized by feature:
export default {
setup() {
// User authentication
const { user, login, logout } = useAuth()
// Data fetching
const { data: items, loading, fetch: fetchItems } = useResource(api.getItems)
// Filtering
const filter = ref('')
const filteredItems = computed(() => {
if (!items.value) return []
return items.value.filter(item =>
item.name.includes(filter.value)
)
})
// Sorting
const sortBy = ref('name')
const sortedItems = computed(() => {
return [...filteredItems.value].sort((a, b) =>
a[sortBy.value] > b[sortBy.value] ? 1 : -1
)
})
// Pagination
const page = ref(1)
const pageSize = 20
const paginatedItems = computed(() => {
const start = (page.value - 1) * pageSize
return sortedItems.value.slice(start, start + pageSize)
})
onMounted(() => {
fetchItems()
})
return {
user,
login,
logout,
items: paginatedItems,
loading,
filter,
sortBy,
page
}
}
}
Each section is self-contained. I can even extract them to separate files:
// composables/useFiltering.js
export function useFiltering(items) {
const filter = ref('')
const filteredItems = computed(() => {
if (!items.value) return []
return items.value.filter(item =>
item.name.includes(filter.value)
)
})
return { filter, filteredItems }
}
// Component
import { useFiltering } from '@/composables/useFiltering'
export default {
setup() {
const { data: items } = useResource(api.getItems)
const { filter, filteredItems } = useFiltering(items)
return { filter, items: filteredItems }
}
}
Watch and WatchEffect
The new watch API is more explicit than Vue 2:
import { ref, watch, watchEffect } from '@vue/composition-api'
const count = ref(0)
const doubled = ref(0)
// Watch specific source
watch(count, (newVal, oldVal) => {
console.log(`Count changed from ${oldVal} to ${newVal}`)
doubled.value = newVal * 2
})
// Watch multiple sources
watch([count, doubled], ([newCount, newDoubled]) => {
console.log(`Count: ${newCount}, Doubled: ${newDoubled}`)
})
// WatchEffect: automatically tracks dependencies
watchEffect(() => {
console.log(`Count is ${count.value}`)
// Automatically re-runs when count changes
})
I prefer watchEffect for most cases - it’s less verbose and automatically tracks dependencies.
TypeScript Integration
This is where Composition API really shines. With Options API, TypeScript support is awkward:
// Vue 2 with TypeScript - not great
export default Vue.extend({
data() {
return {
count: 0 as number // Have to annotate
}
},
computed: {
doubled(): number { // Have to annotate return type
return this.count * 2
}
}
})
With Composition API, inference just works:
import { ref, computed } from '@vue/composition-api'
export default {
setup() {
const count = ref(0) // Inferred as Ref<number>
const doubled = computed(() => count.value * 2) // Inferred as ComputedRef<number>
// TypeScript knows count.value is a number
count.value.toFixed(2) // ✓ OK
count.value.toUpperCase() // ✗ Error
return { count, doubled }
}
}
For complex types:
interface User {
id: number
name: string
}
const user = ref<User | null>(null)
// Type is Ref<User | null>
const users = reactive<User[]>([])
// Type is User[]
Performance Improvements
I ran benchmarks before and after migration. Results:
- Initial render: 5-10% faster (less overhead from Options API)
- Updates: Similar performance
- Memory usage: Slightly lower (fewer internal objects)
The real win is bundle size. With tree-shaking, unused Composition API functions are removed:
- Before: 87KB (minified + gzipped)
- After: 79KB (minified + gzipped)
Not huge, but every KB counts.
Gotchas and Pain Points
1. The .value tax
You have to use .value everywhere with refs:
const count = ref(0)
count.value++ // Can't just do count++
This gets annoying. I sometimes forget and get weird bugs.
2. Losing reactivity
Destructuring reactive objects loses reactivity:
const state = reactive({ count: 0 })
const { count } = state // count is no longer reactive!
// Use toRefs instead
const { count } = toRefs(state) // Now count is a ref
3. Template refs are different
Getting DOM refs requires a different pattern:
// Vue 2
this.$refs.input.focus()
// Composition API
const input = ref(null)
onMounted(() => {
input.value.focus()
})
// In template
<input ref="input" />
4. No this
You can’t use this in setup(). This broke some of our code that relied on this.$router, this.$store, etc.
Solution: use context parameter:
export default {
setup(props, context) {
const router = context.root.$router
const store = context.root.$store
// Or destructure
const { root } = context
root.$router.push('/')
}
}
Should You Migrate?
After migrating 15,000 lines of code, here’s my take:
Migrate if:
- You have large, complex components
- You struggle with logic reuse
- You use TypeScript
- You’re starting a new project
Don’t migrate if:
- Your components are simple
- Your team is new to Vue
- You’re happy with mixins
- You don’t have time for refactoring
The Composition API isn’t better for everything. Simple components are actually more verbose:
// Simple component - Options API is cleaner
export default {
data: () => ({ count: 0 }),
methods: {
increment() { this.count++ }
}
}
// vs Composition API - more boilerplate
export default {
setup() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
}
Conclusion
The Composition API is a powerful addition to Vue. It solves real problems with code organization and reusability.
But it’s not a silver bullet. It adds complexity and has a learning curve. For simple components, the Options API is still better.
My recommendation: learn it, but don’t feel pressured to rewrite everything. Use it where it makes sense - complex components, shared logic, TypeScript projects.
Vue 3 will support both APIs, so you can mix and match. That’s the right approach.
I’m keeping the Composition API in our codebase. The benefits for our complex components outweigh the learning curve. But I’m not migrating simple components - that would be a waste of time.
The future of Vue is flexible, and that’s a good thing.