Component Events and v-model

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

A child talks back to its parent by emitting an event; the parent listens with v-on (@). v-model is built directly on this mechanism, and defineModel() packages the whole round trip into one writable ref. This page also covers fallthrough attributes — what becomes of attributes and listeners the parent puts on a component tag that the child never declared.

Emitting events

In a template the compiler exposes $emit directly:

<template>
  <button @click="$emit('submit')">OK</button>
</template>

In script, get the emit function from the defineEmits() macro (compile-time, <script setup> only, no import):

<script setup>
const emit = defineEmits(['submit', 'cancel'])

function save() {
  // ...do work
  emit('submit')
}
</script>

Declaring emitted events

Declaring events is optional but recommended: it documents the component’s interface and changes fallthrough behaviour — a declared event’s listener is not added to $attrs, so it is not applied to the root element.

Runtime declaration:

const emit = defineEmits(['change', 'delete'])

Type-based declaration — a call signature per event, giving each payload a static type:

const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'delete', id: number, soft: boolean): void
}>()

Since 3.3 the shorter labelled-tuple form is also accepted:

const emit = defineEmits<{
  change: [id: number]
  delete: [id: number, soft: boolean]
}>()

Event validation

The object form of defineEmits() maps each name to a validator that receives the emitted arguments and returns a boolean; false logs a development-only warning:

const emit = defineEmits({
  // no validation
  click: null,
  // validate the payload
  submit: ({ email, password }) => {
    if (!email || !password) {
      console.warn('submit payload missing fields')
      return false
    }
    return true
  }
})

Event names, arguments, and casing

  • Emit with the exact string you declared — Vue does not transform event names, so an event emitted as myEvent cannot be heard as my-event. The reliable convention is kebab-case in both the declaration and the template listener: emit('update-count') paired with @update-count.

  • Any arguments after the name are forwarded to the listener: emit('resize', width, height) calls the handler as onResize(width, height).

Events vs. callback props

Vue can also pass behaviour down as a function prop (:on-select="handleSelect"), the way React does. The official guidance is to prefer emitted events: they work with v-on modifiers, show up in the devtools timeline, and are declared in one place. Reserve function props for the case where the child must call back synchronously and use the return value. See Events vs. Callback Props.

How events power v-model

v-model on a component is sugar for a prop plus its update event. Since Vue 3.4 an argument targets the name + update:name pair, and a bare v-model uses modelValue + update:modelValue:

<!-- these two are equivalent -->
<SearchBox v-model="query" />
<SearchBox :model-value="query" @update:model-value="query = $event" />

Because v-model:title targets an independent pair, a component can carry several v-model bindings at once.

defineModel()

defineModel() (stable since 3.4) implements the child side of v-model. It declares the prop and its update event together and returns a writable ref: reading it reads the prop, assigning to it emits the update.

<script setup>
const model = defineModel()
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

An argument names the bound key, matching v-model:<name> on the parent:

<script setup>
const title = defineModel('title')
const done = defineModel('done')
</script>

<!-- parent: <TodoItem v-model:title="t" v-model:done="d" /> -->

Options follow the name (or come first when there is none) and mirror defineProps options plus a get / set transformer:

const model = defineModel<string>({ required: true })

const count = defineModel<number>('count', {
  default: 0,
  set: (value) => Math.max(0, value)   // clamp on every write
})

Multiple v-model bindings are just multiple defineModel() calls with different names, as above.

Custom modifiers. Destructure the returned pair to read modifiers the parent attached (v-model.capitalize="draft"), and use a set transformer to act on them:

<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    return modifiers.capitalize
      ? value.charAt(0).toUpperCase() + value.slice(1)
      : value
  }
})
</script>

The pre-3.4 equivalent

Before defineModel() the child wired the prop and event by hand. This still works and is, conceptually, what defineModel() compiles to:

<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

function onInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="props.modelValue" @input="onInput" />
</template>

Fallthrough attributes

An attribute or v-on listener the parent puts on a component tag, but which the child declared as neither a prop nor an emit, is a fallthrough attribute. By default Vue applies these to the child’s single root element.

<!-- Child.vue -->
<template>
  <button class="btn"><slot /></button>
</template>
<!-- Parent -->
<Child class="primary" id="save" @focus="onFocus" />
<!-- rendered root: <button class="btn primary" id="save"> plus the focus listener -->
  • class and style are merged with whatever the root element already has.

  • v-on listeners are merged too: the child’s own @focus and the parent’s both run.

  • Everything else (id, data-, aria-, …​) is added, with the parent’s value winning on a clash.

Disabling and redirecting with $attrs

Set inheritAttrs: false to stop the automatic application, then bind $attrs where you actually want it — usually an inner element rather than the wrapper:

<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <div class="field">
    <label><slot /></label>
    <input v-bind="$attrs" />
  </div>
</template>

In <script setup> $attrs is available in the template directly; in script, read it with useAttrs():

import { useAttrs } from 'vue'

const attrs = useAttrs()   // non-reactive object; read it at call time

Multi-root components

A component with more than one root node has no single element to inherit onto, so Vue does not guess — it warns unless you bind $attrs yourself:

<template>
  <header v-bind="$attrs">...</header>
  <main>...</main>
</template>

See also