Vue 3 removed `$on`, `$off` and `$once`; how do you migrate a Vue 2 app that uses `new Vue()` as a global event bus?
answer
- instances are no longer emitters
- $emit survives, listeners do not
- external emitter as a stopgap
- props, provide/inject or a store
basics
~20 sVue 3 component instances no longer implement an event emitter, so a new Vue() bus breaks. A small external emitter restores the pattern quickly, but the lasting fix is props and events, provide/inject, or a shared store; $emit itself remains.
solid answer
~40 sIn Vue 2 every instance had `$on`, `$off`, `$once` and `$emit`, so `const bus = new Vue()` became a global event bus that any component could listen on. Vue 3 removed `$on`, `$off` and `$once`: instances are no longer event emitters, and `$emit` stays to trigger the listeners a parent passes in, normally from its template. For a quick, mechanical migration, replace the bus with a tiny external emitter (the migration guide names mitt and tiny-emitter) behind the same `on`/`off`/`emit` shape. The better fix is to remove the bus: props and events for parent and child, `provide`/`inject` for descendants, and a store for shared state. Also migrate the Vue 2 idiom `this.$once('hook:beforeDestroy', cleanup)` to `onBeforeUnmount(cleanup)` or the `beforeUnmount` option.
code
ts · 18 lines// eventBus.ts: a minimal typed stand-in for a Vue 2 `new Vue()` bus
type Handler<T> = (payload: T) => void
export function createBus<Events extends Record<string, unknown>>() {
const handlers = new Map<keyof Events, Set<Handler<any>>>()
return {
on<K extends keyof Events>(type: K, fn: Handler<Events[K]>) {
if (!handlers.has(type)) handlers.set(type, new Set())
handlers.get(type)!.add(fn)
return () => { handlers.get(type)?.delete(fn) } // unsubscribe
},
emit<K extends keyof Events>(type: K, payload: Events[K]) {
handlers.get(type)?.forEach(fn => fn(payload))
},
}
}
export const bus = createBus<{ 'cart:updated': number }>()go deeper
Recall that $on, $off and $once are gone in Vue 3 but $emit remains for events a parent listens to in its template.
Explain why a new Vue() bus breaks, what a drop-in emitter restores, and which Vue-native patterns replace it: props and events, provide/inject, a store.
Plan the migration in two steps, a stopgap emitter to unblock the upgrade, then refactoring each bus event, and do not forget hook: lifecycle listeners and unsubscribing.
Use the upgrade to retire implicit global messaging: make data flow traceable through props, injection and stores, and measure progress by remaining bus events.
## What Vue 2 allowed Every Vue 2 instance implemented an **event emitter interface**: `$on(event, fn)`, `$once(event, fn)`, `$off(event, fn)` and `$emit(event, ...args)`. Two patterns grew on top of it: - **The global event bus.** `export const bus = new Vue()` in one module, then `bus.$on('cart:updated', fn)` in one component and `bus.$emit('cart:updated', n)` in another, anywhere in the tree. - **Programmatic lifecycle listeners.** Vue 2 emitted `hook:` events for lifecycle stages, so `this.$once('hook:beforeDestroy', stop)` was a popular way to register cleanup right next to the code that created a resource. ## What Vue 3 removed and kept - **Removed:** `$on`, `$off` and `$once`. Component instances **no longer implement the emitter interface**, and there is no migration path for a component listening to its own emitted events. - **Kept:** `$emit` (and `emit` from `defineEmits`), because it triggers the listeners a **parent attached declaratively** in its template, such as `<Child @close="..." />`. - **Renamed:** lifecycle events used from templates changed prefix, `@hook:updated` becomes `@vue:updated`, and `beforeDestroy`/`destroyed` became `beforeUnmount`/`unmounted`. So `new Vue()` as a bus fails in two ways: there is no global `Vue` constructor in the ES module build, and even an instance would have no `$on`. ## Why instances stopped being emitters In Vue 3's virtual DOM, event listeners a parent writes in its template are compiled into **props prefixed with `on`**: `<Child @close="done" />` passes an `onClose` function to the child. `emit('close')` works by looking up that `onClose` prop and calling it. There is no longer a per-instance registry of listeners that code could add to at runtime, which is what `$on` relied on. The same model explains two details of the migration: - listeners now travel inside `$attrs`, so `$listeners` was removed as well; - to listen to events on the **root** component, pass the handler as a root prop: `createApp(App, { onExpand() { ... } })`. ## Replacement options | Situation the bus served | Vue 3 replacement | |---|---| | Quick drop-in, many call sites | A small external emitter library wrapped in the old `$on`/`$off`/`$emit` shape | | Parent and child talking | Props down, events up with `emit` | | Ancestor and deep descendants | `provide` / `inject` | | A component and its slot content | `provide` / `inject` from the component | | App-wide shared state | A store module, or a shared `reactive()` object | | A lifecycle cleanup hook via `$once('hook:...')` | `onBeforeUnmount(fn)` in `setup`, or the `beforeUnmount` option | The migration guide's own list of alternatives is props and events first, then `provide`/`inject`, slots to avoid prop drilling, and global state management. It is blunt about the bus itself: in most circumstances a global event bus is discouraged, because it is simple in the short term and almost invariably a maintenance headache in the long term. Events can be sent from anywhere to anywhere, so it becomes hard to tell who listens, and forgotten `$off` calls leak handlers. ## A migration path 1. **Inventory** every `$on`, `$once` and `$off` call and every `hook:` listener. The migration build reports them under the `INSTANCE_EVENT_EMITTER` and `INSTANCE_EVENT_HOOKS` flags. 2. **Unblock the upgrade** by pointing the bus module at a small emitter with the same method names, so call sites keep working. 3. **Pay down the bus** feature by feature: where a bus event only connects a parent and child, turn it into a prop or an emitted event; where it broadcasts shared state, move that state into a store or a provided reactive object. 4. **Rewrite lifecycle cleanups** from `$once('hook:beforeDestroy', fn)` to `onBeforeUnmount(fn)` or `onUnmounted(fn)`, next to the code that registered the resource. 5. **Keep unsubscribing.** Any remaining emitter subscription must be removed in `onUnmounted`, exactly as `$off` was needed in `beforeDestroy`.
- A Vue 2 component registers cleanup with `this.$once('hook:beforeDestroy', stop)`. What is the Vue 3 equivalent?`$once` is gone, and so is the `beforeDestroy` name. In a `setup` function or `<script setup>`, call `onBeforeUnmount(stop)` right after creating the resource; in the Options API, move the call into the `beforeUnmount` option. From a parent template, `@hook:beforeDestroy` becomes `@vue:beforeUnmount`.
- Can a Vue 3 component still listen to its own `emit` calls?No. The migration guide states there is no migration path for listening to a component's own emitted events, because instances no longer implement the emitter interface. `emit` reaches only the listeners passed in by the parent, normally from its template, so a component's internal reactions should be plain function calls instead.
A Vue 2 event bus is a noticeboard in the lobby: anyone can pin a note and anyone can read it, but nobody knows who is reading or when a note stops mattering. Props and emitted events are addressed mail between known neighbours, which is why they are easier to trace.
saying these in an interview costs you the question
- $emit was removed together with $on and $off in Vue 3
- A Vue 3 component can still create a bus with new Vue()
- An event bus is the recommended way to share state in Vue 3
- this.$once('hook:beforeDestroy', fn) still works in Vue 3
- Handlers on an external emitter are cleaned up automatically on unmount