Vue 2 to Vue 3 Migration (Legacy)
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"inpackage.json, often withvue-template-compiler.- The app starts with
new Vue({ ... }). - Global calls such as
Vue.use(...),Vue.component(...)andVue.prototype.$http = .... - Filters in templates:
{{ price | currency }}. this.$set(...),this.$on(...)andthis.$listeners.- Vue CLI (
vue.config.js,vue-cli-service serve) and Vuex (new Vuex.Store).
The recommended path
- 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. - Replace the build tool. Move from Vue CLI (webpack) to Vite.
- 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. - 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.
- Remove
@vue/compatwhen 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:
- 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:
<!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:
<!-- 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:
$listenerswas removed. Listeners are now part of$attrs, sov-bind="$attrs"passes attributes and listeners together.- The
.nativemodifier (@click.native) was removed. Undeclared listeners automatically fall through to the root element, which is why declaringemitsmatters. - The
modelcomponent option was removed; usemodelValueor a namedv-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.
- // 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 2 | Vue 3 |
|---|---|
Filters {{ x | upper }} | Methods or computed properties |
v-for ran before v-if on one element | v-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 component | Multiple root elements (fragments) allowed |
v-enter, v-leave transition classes | v-enter-from, v-leave-from |
@hook:mounted on a child | @vue:mounted |
Key codes @keyup.13 | Key names @keyup.enter |
Vue.extend(), new Vue() for components | Plain objects or defineComponent() |
Router, store and tooling
- 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 (
createStoreinstead ofnew 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.htmlto the project root with a<script type="module" src="/src/main.js">tag, replaceprocess.env.VUE_APP_Xwithimport.meta.env.VITE_X, and move settings fromvue.config.jstovite.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.$seteverywhere. It still exists in the compat build but is unnecessary in Vue 3; plain assignment works. - Forgetting the renamed lifecycle hooks.
beforeDestroyanddestroyedsilently stop running after migration; rename them tobeforeUnmountandunmounted. - 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 globalVue.*calls becomecreateApp()andapp.*methods.- Filters, the event bus methods,
$listeners,.native,.syncand$setare gone or unnecessary in Vue 3. - Component
v-modelusesmodelValueandupdate:modelValue;.syncbecomesv-model:prop. - The
@vue/compatmigration 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
<!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?
Vue 2's end of life was 31 December 2023; it no longer gets official fixes.
2. What replaces {{ price | rupees }} in Vue 3?
Filters were removed in Vue 3; call a method or use a computed property.
3. What does <Child :title.sync="title" /> become in Vue 3?
The .sync modifier was replaced by named v-model arguments.
4. What is @vue/compat?
The migration build helps you upgrade step by step by showing what still needs fixing.
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
