I've been working with Vue for quite some time now. On a recent project for a client, the dev team had decided to migrate from Vue 2 to Vue 3 - and more specifically - to transition from using the Options API to the Composition API. I've outlined some of the issues I faced and their solutions below, in the hope that my experience will help someone else.
Getting started
Your first port of call should be the official migration guide: https://v3-migration.vuejs.org/. This guide covers the breaking changes between Vue 2 and Vue 3, as well as the migration path. There's also a section on the Composition API, which will be your next stop once you've addressed any breaking changes.
Options API vs Composition API
The Options API is what most Vue 2 developers are familiar with. It organizes your component code into options objects such as data
, methods
, computed
, and watch
:
export default {
name: 'UserProfile',
data() {
return {
user: null,
loading: false,
error: null
}
},
methods: {
async fetchUser() {
this.loading = true;
try {
const response = await fetch(`/api/users/${this.$route.params.id}`);
this.user = await response.json();
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
}
},
computed: {
fullName() {
if (!this.user) return '';
return `${this.user.firstName} ${this.user.lastName}`;
}
},
mounted() {
this.fetchUser();
}
}
The Composition API, on the other hand, lets you organize your code by logical concerns rather than option types. It uses functions like ref
, reactive
, and computed
to create and manipulate reactive state:
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
export default {
name: 'UserProfile',
setup() {
const user = ref(null);
const loading = ref(false);
const error = ref(null);
const route = useRoute();
const fetchUser = async () => {
loading.value = true;
try {
const response = await fetch(`/api/users/${route.params.id}`);
user.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
const fullName = computed(() => {
if (!user.value) return '';
return `${user.value.firstName} ${user.value.lastName}`;
});
onMounted(() => {
fetchUser();
});
return {
user,
loading,
error,
fullName,
fetchUser
};
}
}
ref vs reactive
One of the first things you'll notice when switching to the Composition API is that there are two ways to create reactive state: ref
and reactive
.
ref
is used for primitive values like strings, numbers, and booleans. It wraps your value in an object with a value
property:
import { ref } from 'vue';
const count = ref(0);
console.log(count.value); // 0
// Update the value
count.value++;
console.log(count.value); // 1
reactive
is used for objects and arrays. It makes the entire object reactive and doesn't require the .value
property when accessing or updating it:
import { reactive } from 'vue';
const state = reactive({
count: 0,
user: { name: 'John' }
});
console.log(state.count); // 0
console.log(state.user.name); // 'John'
// Update the values
state.count++;
state.user.name = 'Jane';
At first glance, it might seem like reactive
is the better choice since you don't have to use .value
. However, there are some gotchas to be aware of:
reactive
loses reactivity when you destructure its propertiesreactive
can't be used with primitive valuesref
values can be passed as props to other components
For these reasons, I generally prefer using ref
for most state, even for objects:
const user = ref({
firstName: 'John',
lastName: 'Doe'
});
console.log(user.value.firstName); // 'John'
user.value.firstName = 'Jane';
Organizing code with Composables
One of the biggest advantages of the Composition API is the ability to extract and reuse logic across components. These reusable functions are called "composables" and they're one of the most powerful features of Vue 3.
For instance, if you have a component that fetches a user, you can extract that logic into a composable:
// useUser.js
import { ref } from 'vue';
export function useUser(userId) {
const user = ref(null);
const loading = ref(false);
const error = ref(null);
const fetchUser = async () => {
loading.value = true;
try {
const response = await fetch(`/api/users/${userId}`);
user.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
return {
user,
loading,
error,
fetchUser
};
}
Then, in your component, you can use this composable:
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useUser } from '@/composables/useUser';
export default {
name: 'UserProfile',
setup() {
const route = useRoute();
const { user, loading, error, fetchUser } = useUser(route.params.id);
const fullName = computed(() => {
if (!user.value) return '';
return `${user.value.firstName} ${user.value.lastName}`;
});
onMounted(() => {
fetchUser();
});
return {
user,
loading,
error,
fullName
};
}
}
This approach makes your components much cleaner and more focused on presentation rather than logic.
Template refs
In Vue 2, you could access DOM elements using the $refs
property:
<template>
<input ref="inputEl">
</template>
<script>
export default {
mounted() {
this.$refs.inputEl.focus();
}
}
</script>
In Vue 3's Composition API, template refs work differently. You create a ref with the same name as your template ref, and Vue will automatically assign the DOM element to it:
<template>
<input ref="inputEl">
</template>
<script>
import { ref, onMounted } from 'vue';
export default {
setup() {
const inputEl = ref(null);
onMounted(() => {
inputEl.value.focus();
});
return {
inputEl
};
}
}
</script>
Lifecycle hooks
Lifecycle hooks work a bit differently in the Composition API. Instead of options like mounted
and beforeDestroy
, you import and call functions like onMounted
and onBeforeUnmount
:
import { onMounted, onBeforeUnmount } from 'vue';
export default {
setup() {
onMounted(() => {
console.log('Component is mounted');
});
onBeforeUnmount(() => {
console.log('Component is about to be unmounted');
});
// ...
}
}
Here's a mapping of Vue 2 lifecycle hooks to their Vue 3 equivalents:
beforeCreate
→ No equivalent (code beforesetup()
)created
→ No equivalent (code insetup()
)beforeMount
→onBeforeMount
mounted
→onMounted
beforeUpdate
→onBeforeUpdate
updated
→onUpdated
beforeDestroy
→onBeforeUnmount
destroyed
→onUnmounted
errorCaptured
→onErrorCaptured
Computed properties and watchers
In the Options API, computed properties and watchers were defined in separate options:
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe'
}
},
computed: {
fullName() {
return `${this.firstName} ${this.lastName}`;
}
},
watch: {
fullName(newVal, oldVal) {
console.log(`Name changed from ${oldVal} to ${newVal}`);
}
}
}
In the Composition API, you use the computed
and watch
functions:
import { ref, computed, watch } from 'vue';
export default {
setup() {
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
watch(fullName, (newVal, oldVal) => {
console.log(`Name changed from ${oldVal} to ${newVal}`);
});
return {
firstName,
lastName,
fullName
};
}
}
There's also a watchEffect
function that runs immediately and tracks dependencies automatically:
import { ref, watchEffect } from 'vue';
export default {
setup() {
const firstName = ref('John');
const lastName = ref('Doe');
watchEffect(() => {
console.log(`Current name: ${firstName.value} ${lastName.value}`);
});
return {
firstName,
lastName
};
}
}
The main difference between watch
and watchEffect
is that watch
only runs when the watched value changes, while watchEffect
runs immediately and then re-runs whenever any reactive values used inside it change.
Accessing this
One of the more surprising differences in the Composition API is that this
no longer refers to the component instance within the setup
function. This can be confusing if you're used to accessing props, methods, or computed properties via this
.
For example, in the Options API, you might access a prop like this:
export default {
props: ['userId'],
methods: {
logUserId() {
console.log(this.userId);
}
}
}
In the Composition API, props are passed as the first argument to the setup
function:
export default {
props: ['userId'],
setup(props) {
const logUserId = () => {
console.log(props.userId);
};
return {
logUserId
};
}
}
Similarly, to access route params or the emit function, you need to use the Vue Router's useRoute
composable or the setup
function's context parameter:
import { useRoute } from 'vue-router';
export default {
setup(props, context) {
const route = useRoute();
const userId = route.params.id;
const handleClick = () => {
context.emit('user-clicked', userId);
};
return {
userId,
handleClick
};
}
}
<script setup> syntax
Vue 3.2 introduced a more concise syntax for the Composition API called <script setup>
. This syntax automatically exposes variables and imports to your template without the need for a return
statement:
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useUser } from '@/composables/useUser';
const route = useRoute();
const { user, loading, error, fetchUser } = useUser(route.params.id);
const fullName = computed(() => {
if (!user.value) return '';
return `${user.value.firstName} ${user.value.lastName}`;
});
onMounted(() => {
fetchUser();
});
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">
<h1>{{ fullName }}</h1>
<p>Email: {{ user.email }}</p>
</div>
</template>
This syntax is much more concise and is the recommended way to write Vue 3 components. However, it's worth noting that it's not compatible with the Options API, so you'll need to fully migrate your components to the Composition API to use it.
Troubleshooting common issues
During my migration, I ran into a few issues that weren't immediately obvious. Here are some of the most common ones:
1. Reactive objects losing reactivity
I've already mentioned this, but it's worth mentioning again - if you destructure a reactive object, it loses reactivity:
const state = reactive({ count: 0 });
const { count } = state; // count is no longer reactive!
The way you fix this is to use toRefs
to maintain reactivity:
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0 });
const { count } = toRefs(state); // count is now a ref!
// Access the value with .value
console.log(count.value);
2. Methods not automatically bound to the component
In the Options API, methods were automatically bound to the component instance. In the Composition API, they're just regular functions and don't have access to this
:
// Options API
methods: {
handleClick() {
this.count++; // 'this' refers to the component
}
}
// Composition API
const handleClick = () => {
count.value++; // No 'this', direct access to refs
};
3. Computed properties need to be unwrapped in the template
In the Options API, computed properties were automatically unwrapped in the template. In the Composition API, they behave like refs and need to be unwrapped with .value
in JavaScript, but Vue automatically unwraps them in templates:
<script setup>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
console.log(doubled.value); // Need .value in JavaScript
</script>
<template>
<div>{{ doubled }}</div> <!-- No .value needed in template -->
</template>
Final thoughts
Migrating from the Options API to the Composition API can be a significant undertaking, especially for larger applications. However, the benefits are substantial:
- Better code organization by logical concern rather than option type
- More concise components with the
<script setup>
syntax - More reusable logic through composables
- Better TypeScript support
If you're starting a new project, I'd recommend going straight to Vue 3 with the Composition API. If you're maintaining an existing Vue 2 project, consider using the Composition API plugin to start writing some components with the Composition API before fully migrating.
Remember, you don't have to migrate all at once. Vue 3 still supports the Options API, so you can migrate one component at a time and even mix the two APIs within a single component if needed.
I hope you've found this guide helpful. If you encounter any issues during your migration, the Vue documentation is excellent, and there's a brilliant community ready to help. Good luck, and happy coding!