Skip to content

Events and v-model

Assignment

Martina Hostettler has collected the wishes from the counter.

As a member, I want to reserve a thing right on its card and mark it as a favorite, so that I get to the form faster and find my favorite things again.

The acceptance criteria are:

  1. The button "Reserve" on a card opens the page of the thing with the form.
  2. A button "Favorite" marks a thing and removes the mark again, and a screen reader learns whether the button is pressed.
  3. The header shows how many favorites there are.
  4. Search field and category are components of their own and work as before.

Learning goals

After this chapter you can

  • declare events with defineEmits and emit them with data,
  • react to events in the parent component,
  • check events in the object notation,
  • implement v-model on your own components with defineModel,
  • use several v-model bindings on one component.

Props carry data from the parent to the child. For the way back, the child emits an event, and the parent reacts to it. So the card only reports that someone clicked "Reserve". What happens next is up to the component that owns the data.

defineEmits declares the events of a component and returns a function to emit them. Read the example and work out what appears after a click on the second card.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import ItemCard from './ItemCard.vue'
const message = ref('')
// The parent decides what a click on the button means
function reserve(id) {
message.value = `Reservation requested for the thing with the id ${id}.`
}
</script>
<template>
<ItemCard :id="1" name="Drill" @reserve="reserve" />
<ItemCard :id="6" name="Projector" @reserve="reserve" />
<p>{{ message }}</p>
</template>

ItemCard.vue

ItemCard.vue
<script setup>
defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
})
// The card only reports the click, it does not know what happens next
const emit = defineEmits(['reserve'])
</script>
<template>
<article class="card">
<h2>{{ name }}</h2>
<button type="button" @click="emit('reserve', id)">Reserve</button>
</article>
</template>
<style scoped>
.card {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
</style>

emit('reserve', id) emits the event reserve and passes the id along as data. App listens with @reserve="reserve", just as with a click on an HTML element, and the function reserve receives the id as its argument. Unlike events in the DOM, the events of a component don't travel further up. Only the direct parent can listen.

Props down, events up App is at the top. Below it on the left is ItemCard, App gives it the name and the availability through props, and the card reports the id back with the event reserve. Below it on the right is SearchBar, App gives it the value modelValue, and the search field reports changes with the event update:modelValue. v-model connects both. App.vue owns the data ItemCard.vue props :name, :available events @reserve(id) Data flow down, messages flow up. SearchBar.vue props modelValue events update:modelValue v-model connects both directions

An event is named after what happened, such as reserve or toggleFavorite, not after what the parent should do with it. In the script you write the names in camelCase, in the parent's template usually in kebab-case, @toggle-favorite. Vue treats both spellings as the same, as with props.

Instead of an array, defineEmits also takes an object. Each event then gets a function that checks the data and returns true or false. If it returns false, Vue writes a warning to the console during development, but still emits the event.

App.vue

App.vue
<script setup>
import ItemCard from './ItemCard.vue'
function reserve(id) {
console.log('reserve', id)
}
</script>
<template>
<ItemCard :id="1" name="Drill" @reserve="reserve" />
<!-- Without the colon, the id arrives as the text "6" -->
<ItemCard id="6" name="Projector" @reserve="reserve" />
</template>

ItemCard.vue

ItemCard.vue
<script setup>
defineProps({
id: { type: Number, required: true },
name: { type: String, required: true },
})
// In the object notation every event gets a check, Vue warns in the console if it fails
const emit = defineEmits({
reserve: (id) => Number.isInteger(id),
})
</script>
<template>
<article class="card">
<h2>{{ name }}</h2>
<button type="button" @click="emit('reserve', id)">Reserve</button>
</article>
</template>
<style scoped>
.card {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #c8c8c8;
border-radius: 8px;
}
h2 {
margin: 0;
font-size: 1.05rem;
}
</style>

The second card gets its id as the text "6", because the colon is missing. On load, Vue warns about the wrong type of the prop, and on the click about the data of the event with Invalid event arguments: event validation failed for event "reserve".. Declaring events with defineEmits also documents which events a component knows. Anyone using it sees at a glance what they can listen to.

The search field becomes the component SearchBar. The search text still belongs to App, but the component should be able to read and change it, like an input field with v-model. Since Vue 3.4 there is the macro defineModel for this.

App.vue

App.vue
<script setup>
import { computed, ref } from 'vue'
import SearchBar from './SearchBar.vue'
const names = ['Drill', 'Jigsaw', 'Circular saw', 'Ladder', 'Projector']
const query = ref('')
const visibleNames = computed(() => names.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())))
</script>
<template>
<SearchBar v-model="query" />
<p>Search text in App: {{ JSON.stringify(query) }}</p>
<ul>
<li v-for="name in visibleNames" :key="name">{{ name }}</li>
</ul>
</template>

SearchBar.vue

SearchBar.vue
<script setup>
// The parent binds a value with v-model, the field reads it and changes it
const query = defineModel({ type: String, default: '' })
</script>
<template>
<label for="search">Search</label>
<input id="search" v-model="query" type="search">
</template>

defineModel returns a ref. When SearchBar reads it, it gets the value from App. When it changes it because someone types, the value in App changes. Behind this are a prop and an event of the kind you already know. The two lines do the same.

<SearchBar v-model="query" />
<SearchBar :model-value="query" @update:model-value="query = $event" />

The prop is called modelValue, the event update:modelValue. defineModel declares both and saves you the code that connects them.

A component can bind several values. Each model then gets a name, defineModel('query') and defineModel('category'), and the parent binds them with v-model:query and v-model:category.

App.vue

App.vue
<script setup>
import { ref } from 'vue'
import FilterBar from './FilterBar.vue'
const query = ref('')
const category = ref('')
const categories = ['Camping', 'Garden', 'Tools']
</script>
<template>
<FilterBar v-model:query="query" v-model:category="category" :categories="categories" />
<p>{{ JSON.stringify({ query, category }) }}</p>
</template>

FilterBar.vue

FilterBar.vue
<script setup>
defineProps({ categories: { type: Array, required: true } })
// Each model has a name, the parent binds it with v-model:query and v-model:category
const query = defineModel('query', { type: String, default: '' })
const category = defineModel('category', { type: String, default: '' })
</script>
<template>
<div class="filters">
<label for="query">Search</label>
<input id="query" v-model="query" type="search">
<label for="category">Category</label>
<select id="category" v-model="category">
<option value="">All categories</option>
<option v-for="name in categories" :key="name">{{ name }}</option>
</select>
</div>
</template>

The case app uses this for the catalog page. App owns search text and category, so both survive a visit to the page of a thing, and CatalogView binds them to SearchBar and CategoryFilter through two models.

Favorites show how props and events work together. App owns the ids of the favorites in an array. Every card learns through the prop favorite whether it is a favorite and reports a click with the event toggleFavorite. App changes the array, and Vue updates the card and the header, which counts the favorites.

The button "Favorite" is a toggle. :aria-pressed="favorite" tells a screen reader whether it is pressed, and the CSS can highlight it with the selector [aria-pressed='true']. Until part 5 the app forgets the favorites on reload, then a composable comes along that remembers them.

Work with the example on checking events. Read the code and write down what you expect before you change anything.

  1. Click both buttons "Reserve". What appears in the playground's console afterwards, and which line comes from App?
  2. Put the colon in front of id="6". Which warnings disappear?
  3. Change the check to (id) => id > 0 and remove the colon again. Why doesn't Vue warn on the click anymore?
  4. Switch to the example with defineModel and set the initial value of query in App to "saw". What do the field and the list show?

The exercises build the card and the catalog from the assignment. You find the project in the course repo in the folder exercises/04-components/events/en. Copy the folder, install the packages and run the tests.

Terminal window
npm install
npm test

Exercise Getting started

Add a button "Reserve" with the class reserve to ItemCard. A click emits the event reserve with the id of the thing, declared with defineEmits. App then shows, in an element with the class message, for example "Reservation requested for Drill.".

Solution

ItemCard declares the event and emits it on a click.

ItemCard.vue
// The card only reports what happened, App decides what follows
const emit = defineEmits({
reserve: (id) => Number.isInteger(id),
toggleFavorite: (id) => Number.isInteger(id),
})
ItemCard.vue
<button type="button" class="reserve" @click="emit('reserve', id)">Reserve</button>

App listens with @reserve="reserve" and looks up the thing for the id.

App.vue
function reserve(id) {
const item = items.find((entry) => entry.id === id)
message.value = `Reservation requested for ${item.name}.`
}

The solution already declares the event of the challenge too. For getting started, defineEmits(['reserve']) is enough.

Exercise Going further

Turn the search field into the component src/components/SearchBar.vue. It binds the search text with defineModel, and App uses it with <SearchBar v-model="query" /> instead of the field. The search works as before afterwards.

Solution
SearchBar.vue
<script setup>
// App binds the search text with v-model, the field reads it and changes it
const query = defineModel({ type: String, default: '' })
</script>
<template>
<div class="search">
<label for="search">Search</label>
<input id="search" v-model="query" type="search">
</div>
</template>

query is the ref from defineModel, and the field binds it with v-model like an ordinary ref. App keeps const query = ref('') and the computed value visibleItems and only replaces the label and the field with <SearchBar v-model="query" />.

Exercise Challenge

Add a button "Favorite" with the class favorite to the card. The card gets the prop favorite, shows it with aria-pressed and reports a click with the event toggleFavorite and the id. App owns the ids of the favorites and shows how many there are in an element with the class favorites, "2 favorites" or "1 favorite".

Solution
App.vue
<script setup>
import { computed, ref } from 'vue'
import ItemCard from '@/components/ItemCard.vue'
import SearchBar from '@/components/SearchBar.vue'
import items from '../data/items.json'
const query = ref('')
const message = ref('')
// The ids of the favourite things, App owns them and the cards only show them
const favorites = ref([])
const visibleItems = computed(() => {
const text = query.value.trim().toLowerCase()
return items.filter((item) => item.name.toLowerCase().includes(text))
})
const favoriteCount = computed(() =>
favorites.value.length === 1 ? '1 favorite' : `${favorites.value.length} favorites`,
)
function reserve(id) {
const item = items.find((entry) => entry.id === id)
message.value = `Reservation requested for ${item.name}.`
}
function toggleFavorite(id) {
favorites.value = favorites.value.includes(id)
? favorites.value.filter((entry) => entry !== id)
: [...favorites.value, id]
}
</script>
<template>
<h1>Catalog</h1>
<SearchBar v-model="query" />
<p class="favorites">{{ favoriteCount }}</p>
<p class="message" role="status">{{ message }}</p>
<div class="grid">
<ItemCard
v-for="item in visibleItems"
:id="item.id"
:key="item.id"
:name="item.name"
:category="item.category"
:available="item.availableFrom === null"
:favorite="favorites.includes(item.id)"
@reserve="reserve"
@toggle-favorite="toggleFavorite"
/>
</div>
</template>

toggleFavorite replaces the array with a new one, with or without the id. Changing the array with push and splice would work too, but the new array makes the intent clearer. The card learns through favorites.includes(item.id) whether it is a favorite. It never changes its prop itself, it only reports the click.

MistakeWhat helps
<script setup> contains this.$emit('reserve').There is no this. const emit = defineEmits([...]) returns the function emit.
The grandparent listens to an event of its grandchild and gets nothing.Events don't travel further up. The component in between passes the event on, @reserve="emit('reserve', $event)".
The child changes its prop, and Vue warns that it is readonly.Emit an event, or use defineModel for a bound value.
An event arrives in the parent without data.Pass the data as the second argument of emit, emit('reserve', id).
An AI assistant suggests .sync or the model option.Both come from Vue 2. In Vue 3, defineModel takes over this job.
An AI assistant connects components through an event bus.Props and events are enough between parent and child. For state that many components share, part 7 brings Pinia.

Self-check

  1. How does a child component tell its parent something?
  2. What happens when the check function of an event returns false?
  3. What is v-model on a component short for?
  4. Why does App own the favorites and not the cards?
Answers
  1. It emits an event with the function from defineEmits, with data if needed, and the parent listens with @.
  2. Vue warns in the console during development and still emits the event.
  3. For the prop modelValue together with the event update:modelValue.
  4. All cards and the header need the favorites. The lowest component above all of them is App.

Assignment

Reserving and favorites work, and Martina Hostettler already has the next wish. "Before a request goes out, I'd like to see it once more and confirm it." The volunteer adds that all pages should share the same frame with header and footer. Both need slots, the next chapter.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.