skip to content

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?

level: middleimportance: should knowfreq 45%

answer

  1. undeclared listeners fall through
  2. native listener plus emitted event
  3. declaring removes it from attrs
  4. a native name in emits

basics

~20 s

An 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 s

When 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
vue
<!-- 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

for a junior

Recall that events a component emits should be listed in defineEmits, and that a parent's @click can otherwise land on the root element.

for a middle

Explain the two invocation paths, fallthrough to the root and lookup by emit, and how declaring the event removes the first.

for a senior

Diagnose the double call from the handler's arguments, fix it by declaring or renaming the event, and enforce declared events in review.

for a principal

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.