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:
- The button "Reserve" on a card opens the page of the thing with the form.
- A button "Favorite" marks a thing and removes the mark again, and a screen reader learns whether the button is pressed.
- The header shows how many favorites there are.
- Search field and category are components of their own and work as before.
Learning goals
After this chapter you can
- declare events with
defineEmitsand emit them with data, - react to events in the parent component,
- check events in the object notation,
- implement
v-modelon your own components withdefineModel, - use several
v-modelbindings on one component.
Emitting events
Section titled “Emitting events”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
<script setup>import { ref } from 'vue'
import ItemCard from './ItemCard.vue'
const message = ref('')
// The parent decides what a click on the button meansfunction 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
<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 nextconst 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.
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.
Checking events
Section titled “Checking events”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
<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
<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 failsconst 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.
v-model on your own components
Section titled “v-model on your own components”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
<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
<script setup>// The parent binds a value with v-model, the field reads it and changes itconst 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.
Several v-model bindings
Section titled “Several v-model bindings”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
<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
<script setup>defineProps({ categories: { type: Array, required: true } })
// Each model has a name, the parent binds it with v-model:query and v-model:categoryconst 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
Section titled “Favorites”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.
In code
Section titled “In code”Work with the example on checking events. Read the code and write down what you expect before you change anything.
- Click both buttons "Reserve". What appears in the playground's console afterwards, and which line comes from
App? - Put the colon in front of
id="6". Which warnings disappear? - Change the check to
(id) => id > 0and remove the colon again. Why doesn't Vue warn on the click anymore? - Switch to the example with
defineModeland set the initial value ofqueryinAppto "saw". What do the field and the list show?
Exercises
Section titled “Exercises”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.
npm installnpm testExercise 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.
// The card only reports what happened, App decides what followsconst emit = defineEmits({ reserve: (id) => Number.isInteger(id), toggleFavorite: (id) => Number.isInteger(id),}) <button type="button" class="reserve" @click="emit('reserve', id)">Reserve</button>App listens with @reserve="reserve" and looks up the thing for the id.
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
<script setup>// App binds the search text with v-model, the field reads it and changes itconst 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
<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 themconst 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.
Common mistakes
Section titled “Common mistakes”| Mistake | What 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
Section titled “Self-check”Self-check
- How does a child component tell its parent something?
- What happens when the check function of an event returns
false? - What is
v-modelon a component short for? - Why does
Appown the favorites and not the cards?
Answers
- It emits an event with the function from
defineEmits, with data if needed, and the parent listens with@. - Vue warns in the console during development and still emits the event.
- For the prop
modelValuetogether with the eventupdate:modelValue. - 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.