963 markdown files reformatted with the repository's pinned prettier so pnpm format:check covers the folded tree like every other repo file. The formatter's embedded-language pass also normalized code fences (TS semicolons, closed HTML tags in examples, lowercased CSS hex colors, one renumbered list that skipped an index). Alphanumeric token deltas vs the fold commit were audited file-by-file; all are formatter-equivalent markup normalizations plus the four sanitized skills.
5.7 KiB
title, impact, impactDescription, type, tags
| title | impact | impactDescription | type | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| Component Events Don't Bubble | MEDIUM | Vue component events only reach direct parent - sibling and grandparent communication requires alternative patterns | gotcha |
|
Component Events Don't Bubble
Impact: MEDIUM - Unlike native DOM events, Vue component events do NOT bubble up the component tree. When a child emits an event, only its direct parent can listen for it. Grandparent components and siblings never receive the event.
This is a common source of confusion for developers coming from vanilla JavaScript where events naturally bubble up the DOM.
Task Checklist
- Only expect events from direct child components
- Use provide/inject for deeply nested communication
- Use state management (Pinia) for complex cross-component communication
- Re-emit events at each level if manual bubbling is needed
- Consider whether your component hierarchy is too deep
The Problem
<!-- GrandParent.vue -->
<template>
<!-- This handler NEVER fires for grandchild events -->
<Parent @item-selected="handleSelect" />
</template>
<!-- Parent.vue -->
<template>
<Child />
</template>
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['item-selected']);
function selectItem(item) {
// This event reaches Parent, but NOT GrandParent
emit('item-selected', item);
}
</script>
Solution 1: Re-emit at Each Level (Simple Cases)
Manually forward events through each component.
Correct:
<!-- GrandParent.vue -->
<template>
<Parent @item-selected="handleSelect" />
</template>
<!-- Parent.vue -->
<script setup>
const emit = defineEmits(['item-selected']);
</script>
<template>
<!-- Re-emit to grandparent -->
<Child @item-selected="(item) => emit('item-selected', item)" />
</template>
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['item-selected']);
function selectItem(item) {
emit('item-selected', item);
}
</script>
Drawback: Becomes tedious with deeply nested components.
Solution 2: Provide/Inject (Ancestor Communication)
For deeply nested components, provide a callback from the ancestor.
Correct:
<!-- GrandParent.vue -->
<script setup>
import { provide } from 'vue';
function handleItemSelected(item) {
console.log('Item selected:', item);
}
// Provide the callback to all descendants
provide('onItemSelected', handleItemSelected);
</script>
<template>
<Parent />
</template>
<!-- Parent.vue - No changes needed -->
<template>
<Child />
</template>
<!-- Child.vue -->
<script setup>
import { inject } from 'vue';
// Inject the callback from any ancestor
const onItemSelected = inject('onItemSelected', () => {});
function selectItem(item) {
onItemSelected(item);
}
</script>
Advantages:
- Skips intermediate components
- No prop drilling or re-emitting
- Works at any nesting depth
Solution 3: State Management (Complex Applications)
For cross-component communication, especially between siblings or unrelated components, use Pinia.
Correct:
// stores/selection.js
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useSelectionStore = defineStore('selection', () => {
const selectedItem = ref(null);
function selectItem(item) {
selectedItem.value = item;
}
return { selectedItem, selectItem };
});
<!-- DeepChild.vue - Updates state -->
<script setup>
import { useSelectionStore } from '@/stores/selection';
const store = useSelectionStore();
function handleSelect(item) {
store.selectItem(item);
}
</script>
<!-- SiblingComponent.vue - Reacts to state -->
<script setup>
import { useSelectionStore } from '@/stores/selection';
const store = useSelectionStore();
</script>
<template>
<div v-if="store.selectedItem">Selected: {{ store.selectedItem.name }}</div>
</template>
Solution 4: Event Bus (Use Sparingly)
For truly decoupled components, a simple event bus can work:
// eventBus.js
import mitt from 'mitt';
export const emitter = mitt();
<!-- ComponentA.vue -->
<script setup>
import { emitter } from './eventBus';
function notify() {
emitter.emit('custom-event', { data: 'value' });
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { onMounted, onUnmounted } from 'vue';
import { emitter } from './eventBus';
function handleEvent(data) {
console.log('Received:', data);
}
onMounted(() => emitter.on('custom-event', handleEvent));
onUnmounted(() => emitter.off('custom-event', handleEvent));
</script>
Warning: Event buses make data flow hard to trace. Prefer provide/inject or state management.
Comparison Table
| Method | Best For | Complexity |
|---|---|---|
| Re-emit | 1-2 levels deep | Low |
| Provide/Inject | Deep nesting, ancestor communication | Medium |
| Pinia/State | Complex apps, sibling communication | Medium |
| Event Bus | Truly decoupled, rare cases | Low (but risky) |
Native Events DO Bubble
Note that native DOM events attached to elements still bubble normally:
<!-- GrandParent.vue -->
<template>
<!-- Native click bubbles up from button inside Child -->
<div @click="handleClick">
<Parent />
</div>
</template>
Only Vue component events (those emitted with emit()) don't bubble.