Refactoring Vue 2 Components to Vue 3 Composition API in 7 Practical Steps

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:

  1. reactive loses reactivity when you destructure its properties
  2. reactive can't be used with primitive values
  3. ref 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 before setup())
  • created → No equivalent (code in setup())
  • beforeMountonBeforeMount
  • mountedonMounted
  • beforeUpdateonBeforeUpdate
  • updatedonUpdated
  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted
  • errorCapturedonErrorCaptured

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:

  1. Better code organization by logical concern rather than option type
  2. More concise components with the <script setup> syntax
  3. More reusable logic through composables
  4. 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!