A Vue 3 `<DeleteButton>` emits click from its root `<button>`, and the parent's @click handler runs twice per click; why, and what fixes it?
answer
- undeclared listeners fall through
- native listener plus emitted event
- declaring removes it from attrs
- a native name in emits
basics
~20 sAn undeclared click listener falls through to the root button as a native listener, and the child's emit('click') calls the same handler again. Declaring click in defineEmits removes it from $attrs, so only the emitted event reaches it.
solid answer
~40 sWhen the child does not declare `click`, the parent's `@click` counts as a **fallthrough attribute** and Vue attaches it to the root `<button>` as a native listener. The child's own handler also calls `emit('click', ...)`, and `emit()` finds the same listener in the component's props and calls it. One physical click therefore runs the handler twice, once with a `MouseEvent` and once with the emitted payload. Declaring it, `defineEmits(['click'])`, makes Vue treat `onClick` as a component listener and exclude it from `$attrs`, so it only runs on `emit`. The documentation spells out the consequence: a native event name listed in `emits` no longer responds to native clicks, so the child must emit it deliberately.
code
vue · 19 lines<!-- DeleteButton.vue -->
<script setup lang="ts">
const props = defineProps<{ itemId: string }>()
// Declaring click keeps the parent's @click out of $attrs
const emit = defineEmits(['click'])
function onNativeClick() {
if (window.confirm('Delete this item?')) {
emit('click', props.itemId)
}
}
</script>
<template>
<button type="button" @click="onNativeClick">Delete</button>
</template>
<!-- Without the declaration, <DeleteButton @click="remove" /> calls remove
once natively with a MouseEvent and again with itemId after confirming -->go deeper
Recall that events a component emits should be listed in defineEmits, and that a parent's @click can otherwise land on the root element.
Explain the two invocation paths, fallthrough to the root and lookup by emit, and how declaring the event removes the first.
Diagnose the double call from the handler's arguments, fix it by declaring or renaming the event, and enforce declared events in review.
Adopt a library rule that components declare every event and avoid native names unless they forward the native interaction unchanged.
## The symptom A `<DeleteButton>` renders a single `<button>`, runs a confirmation and then calls `emit('click', itemId)`: - the parent writes `<DeleteButton @click="remove" />` - one click logs two calls to `remove` - one call receives a `MouseEvent`, the other receives `itemId` - with an asynchronous confirmation dialog, the `MouseEvent` call even runs before the user confirms ## Two paths to the same handler Vue has two ways a parent's `v-on` listener on a component can be invoked: 1. **As a component event listener.** `emit('click', ...)` looks up `onClick` in the props the parent passed to the component and calls it. This happens whether or not the event is declared. 2. **As a fallthrough attribute.** Any listener the component does **not** declare in `emits` is part of `$attrs`, and with a single root element Vue attaches it to that element as a **native** DOM listener. With `click` undeclared, both paths are active. The native click on the `<button>` fires path 2 directly; the child's own `@click` handler on the same button confirms and emits, which fires path 1. ## The fix: declare the event Add the name to `defineEmits`: ```vue <script setup lang="ts"> const emit = defineEmits(['click']) </script> ``` Now Vue recognises `onClick` as a **declared emit listener** and excludes it from `$attrs`. Path 2 disappears and the handler runs only when the child emits. The Vue documentation states the consequence directly: if a native event name such as `click` is declared in `emits`, the listener only responds to component-emitted `click` events and no longer to native ones. | `click` declared in emits? | Native click on root | `emit('click')` | Handler calls per click | |---|---|---|---| | no | runs the handler | runs the handler | two | | yes | ignored by the parent's listener | runs the handler | one | ## Choosing a better name Reusing a native name is legal but confusing. A name that says what happened, such as `confirm` or `delete`, avoids the collision entirely and reads better in the parent: `@confirm="remove"`. Keep native names for components that genuinely forward a native interaction unchanged, and then either declare nothing and let the listener fall through, or declare it and emit it yourself, never both. ## Other effects of declaring events Declaring an event changes more than the double call: - **`$attrs` shrinks**: declared listeners never appear in `$attrs`, so `v-bind="$attrs"` on an inner element does not re-attach them. - **Multi-root components** stop warning about extraneous listeners for declared names, because those listeners are no longer fallthrough. - **Typos surface**: once a component declares any events, emitting a name that is neither declared nor an `onX` prop logs a development warning, `Component emitted event "x" but it is neither declared in the emits option nor as an "onX" prop.` ## Covering it with a test A component test pins the contract so the double call cannot return unnoticed: 1. mount `<DeleteButton>` with an `onClick` spy passed as a prop-style listener 2. trigger a click on the rendered button with the confirmation stubbed to accept 3. assert that the spy was called **exactly once**, with the item id rather than an event object A second test with the confirmation stubbed to decline should assert that the spy was **not** called at all. That case fails immediately when the listener falls through natively, because the native path ignores the confirmation. ## Why Vue does not deduplicate for you Vue cannot know that a native `click` and an emitted `click` are the same user action. The fallthrough rule is generic, it applies to every undeclared attribute and listener, and `emit` is generic too, it calls whatever `on*` handler the parent passed. Declaring the event is how the component tells Vue which of the two meanings it intends. ## Diagnosing it quickly 1. Log the handler's first argument: a `MouseEvent` means the native path fired. 2. Inspect the rendered element for listeners: an undeclared listener shows up on the root. 3. Check the child's `defineEmits` for the name. The bug is common in button and link wrappers because their natural event name is also a native one. Declaring every event a component emits, as the docs recommend, prevents it by default.
- After declaring click in emits, how would the Vue parent react to a raw native click on the component?It cannot through `@click` on the component, because that listener is now emit-only. The child has to emit the event, or expose a different name. A parent that needs the native event can wrap the component in its own element and listen there.
- Does declaring an event change anything when the Vue component has several root nodes?Yes. Undeclared listeners on a multi-root component trigger a development warning because Vue cannot pick an element for them. Declared ones are component listeners, not fallthrough attributes, so they never need a root element and do not warn.
saying these in an interview costs you the question
- Declaring click in emits makes the parent's listener fire on native clicks and emits.
- Undeclared component listeners are ignored until the child emits them.
- The double call means Vue emits every event twice in development mode.
- Declared listeners still appear in $attrs and get spread by v-bind="$attrs".
- Emitting an event requires it to be declared, or emit throws.