We all love talking about the Composition API and proxy-based reactivity. But when you are tasked with migrating a massive, legacy Vue 2 codebase to Vue 3, the architecture theory takes a backseat.
What you actually need to know is why your forms are broken, why your app won't mount, and where your event bus disappeared to.
This is the trench-warfare guide to Vue 3. Here are the boring, nitty-gritty, breaking changes you absolutely must refactor.
1. The Global API: Bootstrapping the App
In Vue 2, the global Vue object was mutated directly. If you added a plugin, a mixin, or a directive to Vue, it leaked into every Vue instance created afterwards. This made testing an absolute nightmare.
Vue 3 introduces createApp(), isolating instances perfectly.
The Vue 2 Way (Broken in Vue 3):
import Vue from "vue";
import App from "./App.vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
Vue.directive("focus", {
/* ... */
});
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
The Vue 3 Fix:
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
// Mutations are now scoped to this specific app instance
app.use(router);
app.directive("focus", {
/* ... */
});
app.mount("#app");
2. The v-model Overhaul
This is the breaking change that will cause the most console errors in your UI component library. Vue 3 standardized how v-model works on custom components, making it far more powerful but requiring a tedious refactor.
In Vue 2, v-model defaulted to passing a value prop and listening for an input event. In Vue 3, these defaults have changed to modelValue and update:modelValue.
The Vue 2 Component (CustomInput.vue):
<template>
<input :value="value" @input="$emit('input', $event.target.value)" />
</template>
<script>
export default {
props: ["value"], // <-- Broken in Vue 3
};
</script>
The Vue 3 Component Refactor:
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script>
export default {
props: ["modelValue"],
emits: ["update:modelValue"], // You should explicitly declare emits now
};
</script>
Bonus: Vue 3 allows multiple v-model bindings on a single component (e.g., v-model:title="pageTitle" v-model:content="pageContent"), completely removing the need for the .sync modifier (which has been removed).
3. The Graveyard: Removed Features
Several beloved (but architecturally flawed) Vue 2 features were completely removed.
Filters are Dead
The pipe syntax {{ date | formatDate }} is gone. It conflicted with JavaScript's bitwise operators and required a complex custom parser.
- The Fix: Move your filters into standard methods or computed properties. Better yet, extract them into a global utility file and import them where needed.
The Event Bus is Dead
$on, $off, and $once have been removed from the instance. You can no longer use new Vue() as an event bus to pass data between deeply nested, unrelated components.
- The Fix: Use the Provide/Inject API for hierarchical data passing, Pinia for global state, or install a tiny external library like
mittif you desperately need a pub/sub event bus.
// using 'mitt' for a Vue 3 Event Bus
import mitt from "mitt";
export const emitter = mitt();
// Component A
emitter.emit("user-logged-in", user);
// Component B
emitter.on("user-logged-in", (user) => console.log(user));
$children is Dead
You can no longer access an array of child component instances via this.$children.
- The Fix: If you need to access a child component directly, you must assign a template
refto it.
4. Template Directives & Keys
A few subtle changes in the template compiler will break your UI if you aren't paying attention:
v-ifvsv-forPrecedence: In Vue 2, if you putv-ifandv-foron the same element,v-forhad priority. In Vue 3,v-ifhas priority. Never use them on the same element. Wrap the element in a<template v-if="condition">block instead.<template v-for>Keys: In Vue 2, you had to place the:keyon the child elements inside a<template v-for>. In Vue 3, the:keygoes directly on the<template>tag itself.
5. Lifecycle Hooks Renaming
If you are sticking with the Options API (which is still perfectly valid in Vue 3), be aware that the destruction hooks have been renamed to better match the DOM lifecycle:
beforeDestroy>beforeUnmountdestroyed>unmounted
Furthermore, if you are writing custom directives, their hook functions have been renamed to exactly match the component lifecycle hooks (e.g., bind is now beforeMount, inserted is now mounted).
Conclusion
Migrating to Vue 3 isn't just running a script; it is a methodical process of tracking down API changes, understanding the new Reactivity caveats, and hunting down legacy patterns.
If this looks like a massive headache for your internal team, you aren't alone.
I specialize in taking the stress out of legacy codebases. Let me handle the boring details. Check out my App Modernization & Migration services to see how we can get your app running on Vue 3 safely and efficiently.