Events and v-model
Send events from a child to its parent with defineEmits(), use $event and modifiers, and give your own components a v-model with defineModel().
What you'll learn
When you finish, you can do these.- 1Send an event from a child to its parent with defineEmits()
- 2Read an event's value with $event, listen for Enter only
- 3Give your own component a v-model with defineModel()
- 4Spot and fix an event that was never declared
Picture a shopping cart: a Mug and a Cap, each on a row with a quantity and a Remove button. Written as Vue components, the page is App.vue and each row is CartRow.vue, a child component: one used inside another. The Remove button sits in the row, but the list belongs to the parent, App.vue. The row needs a way to tell its parent that someone clicked.
The Code tab holds that cart: App.vue, CartRow.vue and Stepper.vue, the minus and plus control for a quantity. By the end you can follow each click up to the list and add a button of your own. You need one idea from the Components and props lesson: a parent passes values down to a child as props, which the child can read but never change.
Emit an event from the child
An event is a named message a child sends up to its parent. The child declares its events with defineEmits(), a compiler macro like defineProps(): Vue's compiler replaces it, so you never import it. It returns a function, emit, that sends one.
In CartRow.vue, the Remove button sends a remove event:
<script setup lang="ts">
const { id, name } = defineProps<{ id: number; name: string }>()
const emit = defineEmits<{ remove: [id: number] }>()
</script>
<template>
<button @click="emit('remove', id)">Remove</button>
</template>
The type { remove: [id: number] } says the remove event carries one value, a number. emit('remove', id) sends it with the row's id, a prop App passed down.
The parent listens with @remove, the same @ as in @click. In App.vue, each row carries that listener (the section v-model on your own component explains v-model:qty):
<CartRow
v-for="item in items"
:key="item.id"
:id="item.id"
:name="item.name"
v-model:qty="item.qty"
@remove="remove"
/>
When the event arrives, Vue calls App's remove function with the row's id, and App filters that item out of its list. Click Remove on the Mug row: the row disappears, the total reads 2 items in your cart, and a line reads Removed: Mug. The child never touched the parent's data.
Handlers, $event and modifiers
The code a listener runs is its handler, and it can read the value the event carried. A handler can be a function name, as in @remove="remove": Vue calls it with every value the child sent. Or it can be a short inline statement.
Inside an inline statement, $event is the first value the child emitted. If App had a lastId ref, a box for one value, @remove="lastId = $event" would store the removed row's id in it. On a native element, a plain HTML tag such as a button, $event is the browser's event object.
Modifiers are words after a dot that adjust a listener. On a text field, @keyup.enter="addItem" runs addItem only when the released key is Enter. The first Try-it task adds that field. @click.stop keeps a click from reaching an element around it, such as a clickable row.
v-model on your own component
v-model keeps a ref and a text field in sync (Forms and inputs), and it works on a component you write too. There it is a prop down and an event up: the parent passes the value as a modelValue prop, and the child sends a new value back with an update:modelValue event. The diagram draws the simplest case, a form's name ref bound to a text field component, TextInput.
defineModel(), one more compiler macro, writes both halves for you. It returns a ref, the component's model: reading it gives the parent's value, and assigning to it sends the new value to the parent.
In the cart, each quantity sits between a minus and a plus button. That control is its own component, Stepper.vue, and its model, count, is the number it shows. { required: true } means the parent must always pass a value.
<script setup lang="ts">
defineProps<{ label: string }>()
const count = defineModel<number>({ required: true })
</script>
<template>
<span class="stepper">
<button :aria-label="`One fewer ${label}`" :disabled="count <= 1" @click="count--">−</button>
<output>{{ count }}</output>
<button :aria-label="`One more ${label}`" @click="count++">+</button>
</span>
</template>
Clicking + runs count++, which assigns the model and sends the new number to the parent. The minus button is disabled at 1: taking an item out is Remove's job.
Each aria-label, the name a screen reader announces, ends with the label prop, the item's name: One more Mug, not a bare plus sign.
Naming a model lets a component have more than one: v-model:qty is a qty prop plus an update:qty event. CartRow declares it with defineModel<number>('qty', { required: true }), App binds it with v-model:qty="item.qty", and CartRow hands it on with <Stepper v-model="qty" :label="name" />. With Mug and Cap in the cart, click + on the Mug row: the total reads 4 items in your cart, a number that travelled from Stepper to CartRow to App.
What goes wrong
The first mistake renames an event in one place only.
<button @click="emit('delete', id)">Remove</button>
Clicking Remove does nothing, because App listens for remove and nobody listens for delete. The preview shows Component emitted event "delete" but it is neither declared in the emits option nor as an "onDelete" prop. Read it as a spelling check: emit the name declared in defineEmits, the one the parent listens for.
<button @click="emit('remove', id)">Remove</button>
The second mistake binds v-model to a prop. Picture a Stepper with a number field instead of buttons, taking its count as a prop:
<script setup lang="ts">
const { count } = defineProps<{ count: number }>()
</script>
<template>
<input v-model="count" type="number" />
</template>
The error reads v-model cannot be used on a prop, because local prop bindings are not writable. A prop belongs to the parent, so the child may not write it (Components and props). defineModel() exists for this case: it gives the child a ref it may write, and every write becomes an event.
<script setup lang="ts">
const count = defineModel<number>({ required: true })
</script>
<template>
<input v-model="count" type="number" />
</template>
The third mistake runs a handler twice. Picture a component around one Buy button, BuyButton.vue, not in the Code tab. Its template sends click with $emit, the template's own emit, and never declares the event. The parent counts purchases with <BuyButton @click="bought++" />.
<template>
<button @click="$emit('click')">Buy</button>
</template>
One click adds 2. An undeclared click makes the parent's @click a fallthrough attribute, which lands on the root element (Components and props), so the <button> gets it as a browser listener. The click runs it once and the emit runs it again. Declare the event and the listener stays off the button.
<script setup lang="ts">
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<button @click="emit('click')">Buy</button>
</template>
What to remember
- A child declares its events with
defineEmits()and sends one withemit('name', value). The parent listens with@name. - In an inline handler,
$eventis the first value the child emitted, or the browser event on a native element. - Modifiers adjust a listener:
@keyup.enterfilters keys, and.stopkeeps a click from reaching the elements around it. v-modelon a component is a prop down and an event up.defineModel()returns a ref the child can assign, and naming it, as inv-model:qty, allows more than one.- Declare every event you emit. An undeclared one warns, and an undeclared
clickruns the parent's handler twice.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add items with the Enter key. In App, add
const newName = ref(''), an<input v-model="newName" placeholder="Add an item" @keyup.enter="addItem" />above the list, and anaddItemfunction that pushes{ id: Date.now(), name: newName.value, qty: 1 }ontoitems.valueand clearsnewName. Type Pen and press Enter: a Pen row appears and the total goes up by 1. - Add a Save for later button to CartRow. Declare
save: [name: string]next toremoveindefineEmitsand callemit('save', name)from the button. In App, addconst saved = ref(''), listen with@save="saved = $event", and show<p v-if="saved">Saved for later: {{ saved }}</p>. Click the button on the Cap row and the line reads Saved for later: Cap. - In CartRow, change
emit('remove', id)toemit('delete', id). Click Remove: the row stays, and the preview warns thatdeleteis not a declared event. Change it back and Remove works again.