EthicsComputerCourses
Join training +91 90532 10052

Vue 2 to Vue 3 Migration (Legacy)

Vue.js · Lesson 35 of 41 Advanced 6 min read Updated

Vue 2 reached its end of life on 31 December 2023. It no longer receives bug or security fixes from the Vue team, yet many company projects, tutorials and Stack Overflow answers still use it. As a working developer you will need to read Vue 2 code, and possibly help move it to Vue 3.

This lesson is a practical guide to that move. You will learn how to recognise Vue 2 code, the breaking changes that affect most apps (each with before and after code), how the migration build helps, and which libraries change along the way.

Recognising Vue 2 code

These signs tell you a project is on Vue 2:

  • "vue": "^2.6.14" or "^2.7.x" in package.json, often with vue-template-compiler.
  • The app starts with new Vue({ ... }).
  • Global calls such as Vue.use(...), Vue.component(...) and Vue.prototype.$http = ....
  • Filters in templates: {{ price | currency }}.
  • this.$set(...), this.$on(...) and this.$listeners.
  • Vue CLI (vue.config.js, vue-cli-service serve) and Vuex (new Vuex.Store).
  1. Upgrade to Vue 2.7 first. It is the final Vue 2 release line and already includes the Composition API and <script setup>, so you can modernise code while still on Vue 2.
  2. Replace the build tool. Move from Vue CLI (webpack) to Vite.
  3. Switch to Vue 3 with the migration build, @vue/compat. It is Vue 3 with most Vue 2 behaviours switched back on, and it prints a warning for each deprecated feature your app uses. Fix the warnings one by one.
  4. Upgrade the libraries: Vue Router 3 to the current version, Vuex to Pinia, Vue Test Utils 1 to 2, and UI libraries to their Vue 3 versions.
  5. Remove @vue/compat when no warnings remain.

For a small app, rewriting component by component in Vue 3 may be quicker than the migration build. Either way, good tests make the move much safer.

Creating the app and global APIs

Vue 2 had one global Vue constructor, and plugins changed it for every app on the page. Vue 3 creates separate app instances:

Diff
- import Vue from "vue";
- import App from "./App.vue";
- Vue.use(router);
- Vue.component("BaseButton", BaseButton);
- Vue.prototype.$api = api;
- new Vue({ render: (h) => h(App) }).$mount("#app");
+ import { createApp } from "vue";
+ import App from "./App.vue";
+ const app = createApp(App);
+ app.use(router);
+ app.component("BaseButton", BaseButton);
+ app.config.globalProperties.$api = api;   // better still: provide/inject
+ app.mount("#app");

Changes inside components

The next example is a small Vue 2 component rewritten for Vue 3. It is still the Options API, because that is usually the first step of a migration. The comments mark each Vue 2 feature that had to change:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Migrated component</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
  <style>button { margin: 3px; }</style>
</head>
<body>
  <div id="app"></div>

  <script>
    const { createApp } = Vue;

    const FeeCard = {
      props: ["student"],
      emits: ["paid"],                           // Vue 3: declare emitted events
      methods: {
        // Vue 2: {{ student.fee | rupees }} used a filter. Filters were removed.
        rupees(amount) {
          return "₹" + amount.toLocaleString("en-IN");
        }
      },
      template: `
        <p>
          {{ student.name }}: {{ rupees(student.fee) }}
          <span v-if="student.scholarship">(scholarship {{ student.scholarship }}%)</span>
          <button v-if="!student.paid" @click="$emit('paid', student.id)">Mark paid</button>
          <strong v-else>Paid</strong>
        </p>`
    };

    createApp({
      components: { FeeCard },
      data() {
        return {
          students: [
            { id: 1, name: "Rohan", fee: 18500, paid: false },
            { id: 2, name: "Isha", fee: 18500, paid: false }
          ]
        };
      },
      methods: {
        markPaid(id) {
          this.students.find((s) => s.id === id).paid = true;
        },
        addScholarship() {
          // Vue 2 needed this.$set(this.students[1], "scholarship", 25) for a NEW property.
          this.students[1].scholarship = 25;
        },
        replaceFirst() {
          // Vue 2 could not detect this.students[0] = ...; it needed this.$set or splice().
          this.students[0] = { id: 3, name: "Kabir", fee: 21000, paid: false };
        }
      },
      // Vue 2 called these beforeDestroy and destroyed
      beforeUnmount() {},
      unmounted() {},
      template: `
        <FeeCard v-for="s in students" :key="s.id" :student="s" @paid="markPaid" />
        <button @click="addScholarship">Give Isha a scholarship</button>
        <button @click="replaceFirst">Replace first student</button>`
    }).mount("#app");
  </script>
</body>
</html>

All three buttons work in Vue 3 with plain assignments. Vue 2's reactivity used Object.defineProperty, which could not notice a new property or an array element set by index, so Vue 2 code is full of this.$set and Vue.delete. Vue 3 uses JavaScript Proxies, which see these changes, so you can delete those calls.

v-model, .sync and listeners

Component v-model changed names, and .sync was folded into v-model:

Diff
  <!-- Parent -->
- <PincodeInput v-model="pin" :city.sync="city" />
+ <PincodeInput v-model="pin" v-model:city="city" />

  // Child
- props: ["value", "city"],
- this.$emit("input", newPin);
- this.$emit("update:city", newCity);
+ props: ["modelValue", "city"],
+ emits: ["update:modelValue", "update:city"],
+ this.$emit("update:modelValue", newPin);
+ this.$emit("update:city", newCity);

Related changes:

  • $listeners was removed. Listeners are now part of $attrs, so v-bind="$attrs" passes attributes and listeners together.
  • The .native modifier (@click.native) was removed. Undeclared listeners automatically fall through to the root element, which is why declaring emits matters.
  • The model component option was removed; use modelValue or a named v-model.

Event bus, filters and other removals

Many Vue 2 apps used an empty Vue instance as a global event bus: bus.$emit("logout") in one component and bus.$on("logout", ...) in another. Vue 3 removed $on, $off and $once.

Diff
- // bus.js
- export const bus = new Vue();
- bus.$on("cart-updated", refreshBadge);
- bus.$emit("cart-updated");
+ // Prefer shared state: a Pinia store that both components use.
+ // If you really need an emitter, use a tiny library such as mitt:
+ import mitt from "mitt";
+ export const bus = mitt();
+ bus.on("cart-updated", refreshBadge);
+ bus.emit("cart-updated");

Other changes you will meet:

Vue 2Vue 3
Filters {{ x | upper }}Methods or computed properties
v-for ran before v-if on one elementv-if runs first; filter with a computed property
key on the children of <template v-for>key on the <template> itself
Exactly one root element per componentMultiple root elements (fragments) allowed
v-enter, v-leave transition classesv-enter-from, v-leave-from
@hook:mounted on a child@vue:mounted
Key codes @keyup.13Key names @keyup.enter
Vue.extend(), new Vue() for componentsPlain objects or defineComponent()

Router, store and tooling

Diff
- import VueRouter from "vue-router";
- Vue.use(VueRouter);
- const router = new VueRouter({ mode: "history", routes });
- { path: "*", component: NotFound }
+ import { createRouter, createWebHistory } from "vue-router";
+ const router = createRouter({ history: createWebHistory(), routes });
+ { path: "/:pathMatch(.*)*", component: NotFound }
  • Vuex: Vuex 4 supports Vue 3 (createStore instead of new Vuex.Store), but it is in maintenance mode. Plan to move to Pinia: mutations become direct state changes or actions, and each Vuex module becomes its own store.
  • Vue CLI to Vite: move index.html to the project root with a <script type="module" src="/src/main.js"> tag, replace process.env.VUE_APP_X with import.meta.env.VITE_X, and move settings from vue.config.js to vite.config.js.
  • Tests: Vue Test Utils 2 (and Vitest instead of Jest) for Vue 3 components.

Common mistakes

  • Migrating everything at once. Move in small steps with the migration build and run tests after each step.
  • Keeping this.$set everywhere. It still exists in the compat build but is unnecessary in Vue 3; plain assignment works.
  • Forgetting the renamed lifecycle hooks. beforeDestroy and destroyed silently stop running after migration; rename them to beforeUnmount and unmounted.
  • Ignoring third-party libraries. Check every UI kit and plugin for a Vue 3 version before you start.

Key points

  • Vue 2 reached end of life on 31 December 2023; Vue 2.7 is its final release line.
  • new Vue() and global Vue.* calls become createApp() and app.* methods.
  • Filters, the event bus methods, $listeners, .native, .sync and $set are gone or unnecessary in Vue 3.
  • Component v-model uses modelValue and update:modelValue; .sync becomes v-model:prop.
  • The @vue/compat migration build warns about every Vue 2 feature still in use.
  • Vue Router changes to createRouter(), Vuex is replaced by Pinia, and Vue CLI by Vite.

Exercise

Convert this Vue 2 snippet to Vue 3 (Options API is fine): new Vue({ el: "#app", data: { city: "Pune", temps: [31, 29] }, filters: { celsius: (v) => v + " °C" }, methods: { add() { this.$set(this.temps, 2, 33) } }, beforeDestroy() { console.log("bye") } }) with the template <p v-for="t in temps">{{ t | celsius }}</p><button @click="add">Add</button>.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Converted</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
</head>
<body>
  <div id="app">
    <p>{{ city }}</p>
    <p v-for="(t, i) in temps" :key="i">{{ celsius(t) }}</p>
    <button @click="add">Add</button>
  </div>
  <script>
    Vue.createApp({
      data() {
        return { city: "Pune", temps: [31, 29] };
      },
      methods: {
        celsius(v) {
          return v + " °C";
        },
        add() {
          this.temps[2] = 33;
        }
      },
      beforeUnmount() {
        console.log("bye");
      }
    }).mount("#app");
  </script>
</body>
</html>

new Vue({ el }) became createApp().mount(), data is a function, the filter became a method, this.$set became a plain index assignment, and beforeDestroy was renamed beforeUnmount.

Quick Quiz

1. When did Vue 2 reach end of life?

2. What replaces {{ price | rupees }} in Vue 3?

3. What does <Child :title.sync="title" /> become in Vue 3?

4. What is @vue/compat?

Learn Vue.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call