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 2Vue 3
beforeCreatesetup()
createdsetup()
beforeMountonBeforeMount
mountedonMounted
beforeUpdateonBeforeUpdate
updatedonUpdated
beforeDestroyonBeforeUnmount
destroyedonUnmounted

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:

MetricVue 2Vue 3Improvement
Initial render100ms55ms45% faster
Update50ms25ms50% faster
Bundle size63KB41KB35% smaller
Memory10MB7MB30% 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

  1. Don’t rush migration - Vue 3 is alpha
  2. Start with new components - Use Composition API
  3. Test thoroughly - Breaking changes are subtle
  4. Wait for ecosystem - Many libraries not ready
  5. 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:

  1. Composition API for better code organization
  2. Performance improvements are real
  3. Breaking changes require attention
  4. TypeScript support is much better
  5. Wait for stable release for production

Vue 3 is promising. But for production, wait for stable release and ecosystem maturity.