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:

  1. Logic reuse is awkward - Mixins cause naming conflicts and unclear dependencies
  2. Large components are hard to navigate - Related logic is scattered across options
  3. 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 ref for primitives and when you need to reassign
  • Use reactive for 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 user property
  • Hard to track where user comes 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.