defineEmits: The Child's Output Half of the Contract
Props are the input; events are the output. A ConfirmButton that must tell its parent what the user did — not mutate anything itself — starts with the emit declaration:
<script setup>
const emit = defineEmits(['confirmed', 'cancelled'])
function onClick() {
emit('confirmed') // no payload
emit('cancelled', 'user-abort') // with payload
}
</script>
<template>
<button @click="onClick">Confirm</button>
</template>
defineEmits — another compile-time macro, never imported — returns the emit function, and the declaration itself is the contract. Only declared events are yours to emit; the runtime array form is what Vue checks against in dev. Emitting an event you forgot to declare produces:
[Vue warn] Component emitted event "confirmed" but it is neither declared in an emits option nor as an "onConfirmed" prop.
The warning is not pedantry — an undeclared event leaks into the attrs fallthrough (the third chapter shows why that causes real double-firing bugs), so the declaration does double duty: it documents the output API and excludes the event from the attribute passthrough machinery.
The runtime object form adds per-event payload validation, mirroring prop validators:
const emit = defineEmits({
confirmed: () => true, // payload-free event
quantity: (n) => Number.isInteger(n) && n > 0 // dev-only payload validator
})
emit('quantity', 3) // fine
emit('quantity', -1) // dev warning: Exempt — the validator reports the bad payload
Validation runs in development builds only, same rule as props: it catches contract violations in the dev loop and ships nothing to production.
The typed form is where emits reach parity with typed props. The pre-3.3 callable-type style still works:
const emit = defineEmits<(e: 'change', id: number) => void>()
but the tuple form is the recommended syntax because it reads as a data structure and supports multiple events cleanly:
const emit = defineEmits<{
confirmed: [id: number]
cancelled: [reason: string]
'update:quantity': [value: number]
}>()
emit('confirmed', 42) // ✅
emit('confirmed', '42') // ❌ build error under vue-tsc
emit('confirm', 42) // ❌ event name not in the contract
Named tuple members ([id: number]) are documentation that survives into hover tooltips — the same trick used for typed props.
One mechanics note that trips people up: emit is a plain function with no lifecycle restriction on when you call it, but events fired during a component's own mounted hook can arrive before the parent's listeners are fully wired in some integration patterns — the safe habit is to emit in response to user interaction or in watch callbacks, not during initial mount. If the parent needs the child's current value at mount, that is a ref-exposure or prop-derivation problem, not an event problem.
Three contract-shaping details finish the declaration story. First, declared events are excluded from attribute fallthrough even in multi-root children, where fallthrough has no root to target — the declaration is the only channel that reliably reaches the parent regardless of the child's structure. That is why Vue 3's fragments and dynamic outlets made emits declarations more important than they were in Vue 2: "the child has a single root, so things just work" is no longer an assumption you may lean on. Second, events do not bubble through the component tree: a grandparent cannot listen to a grandchild's emit without every intermediate component re-emitting by hand, so cross-tree notification is a store or provide/inject problem, not an events problem. Third, treat defineEmits the way you would treat a public method signature in any language: the name, the payload shape, and the timing (user interaction or watch callbacks, not initial mount) are the API — everything else about the component is private.
Emitting is half the loop; the parent's @ handler — and the case rules that govern how its name is written — is the other half.
The Parent's Side: @ Handlers, Payload Parameters, and Name Matching
A child's emit('confirmed', 42) reaches the parent through an attribute-side listener whose name is the event prefixed with @:
<ConfirmButton @confirmed="onConfirmed" @cancelled="onCancelled" />
function onConfirmed(id) { // first emit argument lands in the first parameter
console.log('confirmed', id) // 42
}
function onCancelled(reason) { ... }
Payload arguments map positionally to the handler's parameters — exactly like a function call. Multiple arguments work (emit('change', id, source) → @change="(id, source) => ..."), though single-payload objects are the better long-term contract: they let you add fields later without a breaking signature change, which is the same reasoning behind argument-object patterns in any API design.
An inline template also works directly:
<ConfirmButton @confirmed="history.push($event)" />
where $event is the first payload argument (or the native DOM event for native listeners — context distinguishes them, and the ambiguity is why named handler functions are the clearer default in nontrivial code).
The case rule mirrors props, in reverse. Emits are declared in camelCase (updateTitle), and templates listen in kebab-case (@update-title) — Vue's compiler performs the same automatic case transformation for listeners that it performs for props:
const emit = defineEmits<{ 'update:title': [value: string] }>()
emit('update:title', 'New')
<Editor @update:title="title = $event" /> <!-- SFC: both spellings resolve -->
In-DOM templates require the kebab spelling for the same HTML-parsing reason as component tags; in SFCs the compiler normalizes both. The convention that keeps teams sane: declare camelCase, write kebab-case in templates, and never rely on case transformation you can't see — an @updateTitle listener that silently fails in an in-DOM environment is a case bug, not a framework bug.
Naming vocabulary is worth standardizing in a design system because it communicates intent:
| Event name | Means | Example |
|---|---|---|
update:x |
"set this value" — pairs with a prop for v-model | update:quantity |
| past-tense notification | "this happened" — informational | confirmed, removed, opened |
| bare imperative | rare; usually a command the child executed | refresh |
The update:x family is the one v-model on custom components is built from — v-model:quantity="q" on a parent is sugar for :quantity="q" @update:quantity="q = $event", and recognizing that desugaring is how you debug custom v-model components without mystique.
Two listener mechanics settle most case questions. Vue normalizes listener names the same way it normalizes props: in an SFC, @update-title and @updateTitle both match the declared update:title event, because the compiler camelizes the attribute before resolution; in an in-DOM template only the kebab spelling survives the HTML parser, for the same reason component tags must kebab-case there. And the .native modifier from Vue 2 is gone — in Vue 3, @ on a component always targets a component event, and attaching a native DOM listener requires wrapping (@click="$emit('clicked')") or v-on on the inner element directly. So when a listener silently never fires, the first question is which channel you are on: a declared component event, or a native DOM event that never had your handler attached at all.
A parent may also listen to several children with one handler, use modifiers like @change.once (native only for custom components unless the child re-emits through a wrapped listener — a topic best left to the modifier docs), and stop listening by removing the attribute. What it must never do is require the child to emit — event handlers should treat every notification as optional, because a child rendered without a listener is always legal. That asymmetry — props are required contract, events are optional notifications — is the seed of the next topic's one-way data flow discipline.
Typed Emits, Fallthrough Leaks, and Why Declaration Is Not Optional
The strongest argument for declaring emits is not documentation — it is the class of bugs that declaration prevents. Two of them appear in nearly every large Vue codebase at least once.
The undeclared-event leak. Vue's attr fallthrough mechanism passes any undeclared attribute — including listener-shaped ones — down to the child's root element. An undeclared change listener, intended as a component event, becomes a native DOM change listener on the root <input>:
<!-- parent -->
<SearchField @change="doSearch" /> <!-- 'change' not declared in child's emits -->
<!-- child's root: <input class="field" /> -->
Now doSearch fires when the native input's change event bubbles — with a native Event object as $event, at a different time than intended, and possibly twice (once natively, once when someone later adds emit('change')). The declaration flips the event out of $attrs and into the component's contract, restoring exactly one well-timed invocation with the payload you designed. defineEmits is the switch that moves a name from the fallthrough channel to the event channel.
The missing build-time check. Without typed emits, a refactor that renames confirmed to confirm succeeds silently — the child's emit('confirm') is a string call, the parent's @confirmed is dead markup, and the app's failure mode is a button that does nothing. Typed emits make that rename a compile error in the child and in the parent, because vue-tsc validates both sides of the contract:
const emit = defineEmits<{
'update:quantity': [value: number]
}>()
The parent's @update:quantity="quantity = $event" is checked against the tuple: wrong event name or a string assigned to a number-typed payload both fail the build. The parity with props is exact — type-based declarations turn the component boundary into a checked interface instead of a stringly-typed handshake.
A third, quieter win: predictable attrs. A child that declares its full event surface has clean $attrs — the rest attributes (class, style, data-*, aria attributes) fall through to the root exactly as the parent wrote them. A child with undeclared events has polluted $attrs, which matters when the child also binds v-bind='$attrs' explicitly (multi-root children, or wrapping a third-party input). Teams that treat the emits declaration as complete — every event the component can produce is in the list — get fallthrough behavior they can reason about.
A concrete trace of the double-fire makes the fallthrough argument stick. The child's root is <input>; the parent writes @change="save"; the child declares no emits. Fallthrough binds save as a native listener on the input, so tabbing out calls save(nativeEvent) — a DOM Event object where the parent expected a payload. Two sprints later someone adds emit('change', payload) to the child, and the same blur now calls save twice: once natively, once with the payload, in registration order. Declaring change in defineEmits retroactively fixes both symptoms, because the declaration is what removes the name from $attrs. Naming conventions prevent the next class of bugs: avoid reusing native names (change, input, focus) for component events — the collision above is the mildest form — reserve update:x exclusively for v-model pairs, and use past-tense notifications (confirmed, deleted) for facts that already happened. A parent reading your template can then predict every event without opening the child, which is what a contract is for.
The one-line discipline for review: every event a component can emit appears in defineEmits, with a type for its payload, in the same PR that first emits it. That keeps three systems consistent at once — the fallthrough exclusion, the parent's listener autocomplete, and the compiler's cross-boundary check — and it sets up the larger rule these events serve: the child reports, the parent decides, and the data flows one way. That rule, in full, is One-Way Data Flow Rules.
Quiz
1. What does defineEmits(['confirmed']) return, and what happens if you emit an event not in the declaration?
2. A child declares `defineEmits<{ 'update:title': [value: string] }>()` and the parent writes @update:title="title = $event". What does $event contain?
3. Why does a child's undeclared 'change' event sometimes fire with a DOM Event object at unexpected times?
4. What does typing emits in defineEmits<...>() actually buy you, beyond documentation?
Flashcards
Question
What does defineEmits return, and what are its three declaration forms?
Click to reveal answer
Answer
The emit function. Forms: runtime array (['change','close']), runtime object with dev-only payload validators ({ change: (n) => n > 0 }), and types — callable style defineEmits<(e: 'change', id: number) => void>() or the recommended tuple style defineEmits<{ change: [id: number] }>().
Question
How does a parent receive an emitted payload?
Click to reveal answer
Answer
Via @event="handler": emitted arguments map positionally to handler parameters, and $event is the first payload argument for inline handlers. Case mirrors props — declare camelCase ('update:title'), write kebab-case in templates (@update:title); in-DOM templates require kebab.
Question
Why must every emitted event be declared in defineEmits?
Click to reveal answer
Answer
Declaration moves the name out of attrs fallthrough: undeclared listeners bind to the child's root element as native DOM listeners (double-firing, DOM Event objects as $event), and dev warns 'neither declared in an emits option nor as an onX prop'. Declared events become the documented, checked output contract.
Question
What do typed emits check, and when?
Click to reveal answer
Answer
Under vue-tsc, both sides of the boundary: the child's emit calls (event name exists, payload matches the tuple) and the parent's @listeners (handler exists, $event type checks). Compile-time only — runtime payload validation requires the object form and runs in dev builds only.
Question
How does v-model relate to emits?
Click to reveal answer
Answer
v-model on a component desugars to a modelValue prop plus an @update:modelValue listener; with an argument, v-model:quantity pairs :quantity with @update:quantity. The update:x event family is the mechanical half of every custom v-model — emit('update:x', value) drives the parent's assignment.
Revision Notes
Key Takeaways
- 1. defineEmits returns the dispatch function and declares the child's output contract; only declared events belong to the component's API
- 2. Undeclared events leak into attrs fallthrough and become native root-element listeners — the source of mysterious double-firing and wrong $event objects
- 3. Payloads map positionally to parent handler parameters; $event is the first argument; camelCase declares, kebab-case listens in templates
- 4. Typed emits check both sides at build time — emit calls and parent listeners — turning renames and payload drift into compile errors
- 5. update:x events are the mechanical half of v-model; events flow up while props flow down, and both sides treat the other as optional
Interview Tips
- • "How does a child component talk to its parent?" — defineEmits and @ listeners: state the one-way contract (props down, events up), show the typed tuple declaration, and mention the parent treats handlers as optional
- • "Why declare events in emits if they work anyway?" — fallthrough: undeclared listeners bind natively to the root element and double-fire; declaration excludes them from attrs, documents the API, and enables typed checking
- • "Explain what v-model:quantity desugars to" — :quantity="q" @update:quantity="q = $event"; knowing the expansion lets you debug custom v-model components mechanically
- • "How do you type-check event payloads?" — defineEmits tuple types plus vue-tsc validate emit calls and parent listeners; runtime validators exist in the object form but are dev-only
Cheat Sheet
Emitting Events
Child:
const emit = defineEmits<{
confirmed: [id: number]
'update:quantity': [value: number]
}>()
emit('confirmed', 42) // typed: wrong name/payload = build error
Parent:
<ConfirmButton @confirmed="onConfirmed" @update:quantity="quantity = $event" />
Mapping: emit args → handler params positionally · $event = first payload · declare camelCase, write kebab in templates (in-DOM: kebab only).
Declaration does three jobs: documents the output API · moves the event OUT of attrs fallthrough (undeclared → native root listener → double-fire with DOM Event) · enables typed both-sides checking under vue-tsc.
Forms: array (names only) · object (dev-only payload validators) · type tuple (recommended) / callable type (legacy).
Vocabulary: update:x = set-value (v-model sugar: v-model:q ≡ :q + @update:q) · past tense (confirmed) = notification · treat every event as optional for the parent (a child without listeners is legal).
Hygiene: every new emit lands in defineEmits in the same PR · single-object payloads for evolvable contracts · emit in interaction/watch callbacks, not during mount.