Why Using Undocumented @vue:mounted in Vue 3 Is Risky and Better Alternatives
This article examines the undocumented @vue:mounted lifecycle syntax in Vue 3 dynamic components, explains its advantages and risks, compares alternative approaches like emit events, ref access, and provide/inject, and offers practical review recommendations for stable, maintainable code.
Preface
Hello, I am the reviewer. While doing a code review I found a line of code that uses
<component :is="currentComponent" @vue:mounted="handleMounted" />. I wondered what syntax this was, thought it resembled the Vue 2
@hook:mountedsyntax, searched the Vue 3 documentation and found nothing, and later learned it is an undocumented feature.
Starting from a Dynamic Component
The requirement is to obtain some information after a child component is loaded, updated, or destroyed. The following example shows a dynamic component that uses the undocumented lifecycle listeners.
<template>
<div class="demo-container">
<h2>Dynamic Component Load Monitoring</h2>
<div class="status">Current component status: {{ componentStatus }}</div>
<div class="controls">
<button @click="loadComponent('ComponentA')">Load Component A</button>
<button @click="loadComponent('ComponentB')">Load Component B</button>
<button @click="unloadComponent">Unload Component</button>
</div>
<!-- Undocumented usage -->
<component
:is="currentComponent"
v-if="currentComponent"
@vue:mounted="handleMounted"
@vue:updated="handleUpdated"
@vue:beforeUnmount="handleBeforeUnmount"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const currentComponent = ref(null)
const componentStatus = ref('No component')
const handleMounted = () => {
componentStatus.value = '✅ Component mounted'
console.log('Component mounted')
}
const handleUpdated = () => {
componentStatus.value = '🔄 Component updated'
console.log('Component updated')
}
const handleBeforeUnmount = () => {
componentStatus.value = '❌ Component about to unmount'
console.log('Component about to unmount')
}
const loadComponent = (name) => {
currentComponent.value = name
}
const unloadComponent = () => {
currentComponent.value = null
componentStatus.value = 'No component'
}
</script>The main advantage of using
@vue:mounted(and the other lifecycle hooks) is that all lifecycle handling logic can be placed in a single place – the parent component – without having to modify each possible child component.
However, the syntax is not documented, which raises concerns about stability and future compatibility.
Deep Dive: Undocumented Feature
Searching the Vue GitHub discussions revealed the following answer from the core team:
"This feature is not designed for user applications, which is why we decided not to document it."
Source: https://github.com/orgs/vuejs/discussions/9298
✅ The feature works and can be used.
❌ It is not guaranteed to be stable.
⚠️ It may be removed in future versions.
🚫 It is not recommended for production use.
The Vue 3 migration guide mentions converting
@hook:(Vue 2) to
@vue:(Vue 3) for compatibility, not as an encouraged practice.
Why the code looks fine?
Because all lifecycle listeners are centralized in the parent, the code appears concise and easier to manage.
Review Suggestions
Considering safety and stability, the following solutions are recommended.
Solution 1: Child Component Emits Events (Recommended)
Modify each child component to emit lifecycle events.
<!-- ComponentA.vue -->
<template>
<div class="component-a">
<h3>I am Component A</h3>
<button @click="counter++">Clicks: {{ counter }}</button>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const emit = defineEmits(['lifecycle'])
const counter = ref(0)
onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentA' })
})
onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentA' })
})
onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentA' })
})
</script> <!-- ComponentB.vue -->
<template>
<div class="component-b">
<h3>I am Component B</h3>
<input v-model="text" placeholder="Enter text" />
<p>{{ text }}</p>
</div>
</template>
<script setup>
import { ref, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const emit = defineEmits(['lifecycle'])
const text = ref('')
onMounted(() => {
emit('lifecycle', { type: 'mounted', componentName: 'ComponentB' })
})
onUpdated(() => {
emit('lifecycle', { type: 'updated', componentName: 'ComponentB' })
})
onBeforeUnmount(() => {
emit('lifecycle', { type: 'beforeUnmount', componentName: 'ComponentB' })
})
</script>Parent component usage:
<component :is="currentComponent" v-if="currentComponent" @lifecycle="handleLifecycle" /> const handleLifecycle = ({ type, componentName }) => {
const statusMap = {
mounted: '✅ Mounted',
updated: '🔄 Updated',
beforeUnmount: '❌ About to unmount'
}
componentStatus.value = `${componentName} ${statusMap[type]}`
console.log(`${componentName} ${type}`)
}Pros: stable, officially recommended. Cons: requires modifying each child component, leading to some repetitive code.
Solution 2: Access via ref (Specific Scenarios)
If you need to call methods on the component instance directly.
<component :is="currentComponent" v-if="currentComponent" ref="dynamicComponentRef" /> import { ref, watch, nextTick } from 'vue'
const dynamicComponentRef = ref(null)
watch(currentComponent, async (newComponent) => {
if (newComponent) {
await nextTick()
console.log('Component instance:', dynamicComponentRef.value)
componentStatus.value = '✅ Component mounted'
if (dynamicComponentRef.value?.someMethod) {
dynamicComponentRef.value.someMethod()
}
}
}, { immediate: true })Pros: direct access to component methods and data. Cons: only captures the mount phase; updates and unmounts are not observed.
Solution 3: provide/inject (Deep Communication)
Suitable for complex nested component trees.
<!-- Parent -->
<script setup>
import { provide, ref } from 'vue'
const componentStatus = ref('No component')
const lifecycleHandler = {
onMounted: (name) => {
componentStatus.value = `✅ ${name} mounted`
console.log(`${name} mounted`)
},
onUpdated: (name) => {
componentStatus.value = `🔄 ${name} updated`
console.log(`${name} updated`)
},
onBeforeUnmount: (name) => {
componentStatus.value = `❌ ${name} about to unmount`
console.log(`${name} about to unmount`)
}
}
provide('lifecycleHandler', lifecycleHandler)
</script>
<template>
<div>
<div class="status">{{ componentStatus }}</div>
<component :is="currentComponent" v-if="currentComponent" />
</div>
</template> <!-- Child -->
<script setup>
import { inject, onMounted, onUpdated, onBeforeUnmount } from 'vue'
const lifecycleHandler = inject('lifecycleHandler', {})
const componentName = 'ComponentA' // each component sets its own name
onMounted(() => lifecycleHandler.onMounted?.(componentName))
onUpdated(() => lifecycleHandler.onUpdated?.(componentName))
onBeforeUnmount(() => lifecycleHandler.onBeforeUnmount?.(componentName))
</script>Pros: works across deep component hierarchies. Cons: adds boilerplate and requires explicit provision.
Comparison of Solutions
Emit events – easy to implement, highly reliable, best choice for most scenarios.
Ref access – moderate difficulty, good reliability, suitable when you need to call component methods.
provide/inject – higher difficulty, good reliability, ideal for deep nested communication.
@vue:mounted – minimal difficulty, low reliability, experimental use only, not recommended for production.
Conclusion
The code review shows that while the undocumented
@vue:[lifecycle]syntax offers centralized management in dynamic component scenarios, its lack of documentation makes it unstable and risky for production. Prefer official patterns such as child‑emitted events, ref access, or provide/inject, and plan a migration away from undocumented APIs.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.