Forms
Assignment
Now comes the part of the app the board has been waiting for most.
As a member, I want to reserve a thing for a period of time, so that it is ready when I pick it up.
The acceptance criteria are:
- The form asks for the member number, the first day and the last day.
- Things can only be picked up and brought back on Wednesday or Saturday, and a loan lasts at most seven days.
- Errors appear next to the field as soon as someone leaves it or sends the form, and a screen reader reads them out.
- After sending, the app sums up the request.
Learning goals
After this chapter you can
- bind every kind of field to the state with
v-model, - use the modifiers
.trim,.numberand.lazy, - send a form with
@submit.prevent, - check input with a computed object of errors,
- show error messages so that everyone notices them.
v-model
Section titled “v-model”In the last chapter, v-model connected the search field to a ref. For a text field it is short for two things you already know. The two lines do the same.
<input :value="query" @input="query = $event.target.value"><input v-model="query">:value writes the ref's value into the field, and @input writes the field's value into the ref on every input. Depending on the kind of field, Vue picks the matching property and event. For checkboxes and radio buttons it binds checked and listens to change, for a select value and change.
The ref determines the initial value. v-model ignores a value, checked or selected attribute in the HTML.
Every kind of field
Section titled “Every kind of field”The example shows all the usual fields and, below them, the values that end up in the refs. First work out the type of each value, then change the fields in the playground.
<script setup>import { ref } from 'vue'
const note = ref('')const days = ref(3)const rulesRead = ref(false)const extras = ref([])const pickup = ref('Wednesday')const category = ref('')const firstDay = ref('')</script>
<template> <form class="fields"> <label for="note">Note</label> <textarea id="note" v-model="note"></textarea>
<label for="days">Number of days</label> <input id="days" v-model="days" type="number" min="1" max="7">
<label for="first-day">First day</label> <input id="first-day" v-model="firstDay" type="date">
<label for="category">Category</label> <select id="category" v-model="category"> <option value="">Please choose</option> <option>Tools</option> <option>Garden</option> <option>Kitchen</option> </select>
<fieldset> <legend>Accessories</legend> <label><input v-model="extras" type="checkbox" value="Battery"> Battery</label> <label><input v-model="extras" type="checkbox" value="Drill bit set"> Drill bit set</label> </fieldset>
<fieldset> <legend>Pickup</legend> <label><input v-model="pickup" type="radio" value="Wednesday"> Wednesday</label> <label><input v-model="pickup" type="radio" value="Saturday"> Saturday</label> </fieldset>
<label><input v-model="rulesRead" type="checkbox"> I have read the loan rules.</label> </form> <pre>{{ { note, days, firstDay, category, extras, pickup, rulesRead } }}</pre></template>
<style>.fields { display: grid; gap: 0.4rem; max-width: 22rem;}</style>A single checkbox delivers true or false. Several checkboxes bound to the same array collect the values of the checked boxes. Radio buttons deliver the value of the selected button, a select the value of the selected option, and an option without a value attribute delivers its text. A date field delivers a string such as 2026-11-18 or an empty string. A number field with type="number" delivers a number, because there Vue applies the .number modifier by itself.
Every field needs a label. <label for="days"> belongs to the field with the id days, a click on it puts the focus into the field, and a screen reader reads the label out. Radio buttons and checkboxes that belong together are grouped by a <fieldset> with a <legend>.
Modifiers
Section titled “Modifiers”Three modifiers change what v-model writes into the ref. .trim removes spaces at the start and end. .number converts the input into a number and leaves the text as it is if that isn't possible. .lazy writes only when someone leaves the field, instead of on every character.
<script setup>import { ref } from 'vue'
const member = ref('')const days = ref('')const note = ref('')</script>
<template> <p> <label for="member">Member number with .trim</label> <input id="member" v-model.trim="member"> </p> <p> <label for="days">Days with .number</label> <input id="days" v-model.number="days"> </p> <p> <label for="note">Note with .lazy</label> <input id="note" v-model.lazy="note"> </p> <pre>{{ JSON.stringify(member) }} {{ typeof days }} {{ days }} {{ JSON.stringify(note) }}</pre></template>Sending a form
Section titled “Sending a form”Fields belong in a <form> element with a <button type="submit">. Then the Enter key in a field sends the form too, as people expect. Left alone, the browser sends the form to the server and reloads the page, and the whole state of the app is gone. @submit.prevent="submit" prevents that and calls the function submit instead.
Checking input
Section titled “Checking input”The check belongs in a computed value. It returns an object with a message for every field where something is wrong. If the object is empty, the form is valid. Because computed recalculates by itself, the messages always match the current state of the fields.
<script setup>import { computed, ref } from 'vue'
const name = ref('')const category = ref('')const deposit = ref('')const touched = ref({})const submitted = ref(false)const thanks = ref('')
// One message per field with a problem, an empty object means the form is validconst errors = computed(() => { const result = {} if (name.value.trim() === '') result.name = 'Enter the name of the thing.' if (category.value === '') result.category = 'Choose a category.' if (typeof deposit.value !== 'number' || deposit.value < 10 || deposit.value > 80) { result.deposit = 'The deposit is between 10 and 80 francs.' } return result})
// A message appears once someone has left the field or sent the formconst shownErrors = computed(() => { const result = {} for (const [field, message] of Object.entries(errors.value)) { if (touched.value[field] || submitted.value) result[field] = message } return result})
function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return thanks.value = `Thank you for suggesting ${name.value.trim()}.` name.value = '' category.value = '' deposit.value = '' touched.value = {} submitted.value = false}</script>
<template> <h1>Suggest a thing</h1> <form class="fields" novalidate @submit.prevent="submit"> <label for="name">Name of the thing</label> <input id="name" v-model="name" :aria-invalid="!!shownErrors.name" :aria-describedby="shownErrors.name ? 'name-error' : undefined" @blur="touched.name = true" > <p v-if="shownErrors.name" id="name-error" class="error">{{ shownErrors.name }}</p>
<label for="category">Category</label> <select id="category" v-model="category" :aria-invalid="!!shownErrors.category" :aria-describedby="shownErrors.category ? 'category-error' : undefined" @blur="touched.category = true" > <option value="">Please choose</option> <option>Tools</option> <option>Garden</option> <option>Kitchen</option> <option>Camping</option> <option>Kids</option> <option>Parties</option> </select> <p v-if="shownErrors.category" id="category-error" class="error">{{ shownErrors.category }}</p>
<label for="deposit">Deposit in francs</label> <input id="deposit" v-model="deposit" type="number" min="10" max="80" :aria-invalid="!!shownErrors.deposit" :aria-describedby="shownErrors.deposit ? 'deposit-error' : undefined" @blur="touched.deposit = true" > <p v-if="shownErrors.deposit" id="deposit-error" class="error">{{ shownErrors.deposit }}</p>
<button type="submit">Suggest</button> </form> <!-- The status element is always there, so screen readers announce new text --> <p role="status">{{ thanks }}</p></template>
<style>.fields { display: grid; gap: 0.4rem; max-width: 22rem;}.error { margin: 0; color: #a3341f;}</style>Someone filling in a field for the first time shouldn't see an error after the first letter. The example shows a message only once someone has left the field or sends the form. touched remembers the fields that were left, submitted the attempt to send, and shownErrors contains only the messages that may already appear.
So that everyone notices the messages, they appear as text next to the field and not just as red color. aria-describedby connects the field with the element of the message, and a screen reader then reads it out together with the label. aria-invalid="true" says that the input isn't right. The confirmation lives in an element with role="status". A screen reader reads out new text in it by itself, as long as the element was already on the page before.
The novalidate attribute on the form switches off the browser's own messages. They look different in every browser and appear in the browser's language, not the app's. Attributes such as min and max are still worth it, because a number field then shows matching arrows and a date field a matching calendar.
Dates need no arithmetic for comparing. Strings such as 2026-11-18 can be compared directly, because the order of the characters matches the order of the days. new Date('2026-11-18').getUTCDay() returns the weekday, 0 for Sunday up to 6 for Saturday. JavaScript reads a date without a time as midnight in UTC, as you know from the JS workshop. That's why getUTCDay applies, not getDay.
In code
Section titled “In code”Work with the example on the suggestion. Read the code and write down what you expect before you change anything.
- What appears if you click "Suggest" right away? And what if you enter a name and leave the field with the Tab key?
- Enter 85 as the deposit and leave the field. Which message appears, and which attributes does the field have afterwards? Check with the browser's developer tools.
- Remove
.preventand send the form with valid values. What happens to the preview, and why? - Remove
novalidateand add therequiredattribute to the name. What does the browser show when you send the form with an empty name?
Exercises
Section titled “Exercises”The exercises build the reservation request on the page of the drill. You find the project in the course repo in the folder exercises/03-vue/forms/en. Copy the folder, install the packages and run the tests.
npm installnpm testThe tests act as if today were Monday, November 2, 2026. The file src/dates.js provides three helpers, today() for today's date, daysBetween(from, to) for the number of days in between and formatDate(date) for display.
Exercise Getting started
Bind the three fields with v-model to the refs member, from and to, the member number with .trim. When the form is sent, the page doesn't reload but shows the summary in an element <p class="summary" role="status">, for example "Reservation request from M-117 for Drill from 11/18/2026 to 11/21/2026 recorded.".
Solution
The fields get v-model.trim="member", v-model="from" and v-model="to", and the form calls submit when it is sent.
<form class="fields" novalidate @submit.prevent="submit">function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return summary.value = `Reservation request from ${member.value} for ${item.name} from ${formatDate(from.value)} to ${formatDate(to.value)} recorded.`}The two lines with submitted and the early return on errors come with the next exercise. summary is a ref holding an empty string, so the status element is there from the start.
Exercise Going further
Check the input by these rules and show each message in an element with the id member-error, from-error or to-error and the class error. The field then gets aria-invalid="true" and aria-describedby with the id of the message. The messages appear as soon as someone sends the form, and only a valid form produces a summary.
| Field | Rule | Message |
|---|---|---|
| Member number | "M-" and three digits | The member number looks like M-117. |
| First day | not empty | Choose the first day. |
| First day | not before today | The first day is in the past. |
| Last day | not empty | Choose the last day. |
| Last day | not before the first day | The last day is before the first. |
| Last day | at most seven days after the first | A loan lasts at most seven days. |
Solution
The check lives in a computed value. Here are the rules for the member number and the first day.
const errors = computed(() => { const result = {} if (!/^M-\d{3}$/.test(member.value)) { result.member = 'The member number looks like M-117.' } if (from.value === '') { result.from = 'Choose the first day.' } else if (from.value < today()) { result.from = 'The first day is in the past.'For the last day, the three rules from the table follow.
if (to.value === '') { result.to = 'Choose the last day.' } else if (from.value !== '' && to.value < from.value) { result.to = 'The last day is before the first.' } else if (from.value !== '' && daysBetween(from.value, to.value) > 7) { result.to = 'A loan lasts at most seven days.'The field and its message are connected through the id.
<label for="member">Member number</label> <input id="member" v-model.trim="member" :aria-invalid="!!shownErrors.member" :aria-describedby="shownErrors.member ? 'member-error' : undefined" @blur="touched.member = true" > <p v-if="shownErrors.member" id="member-error" class="error">{{ shownErrors.member }}</p>For this exercise, submitted && errors.member is enough as the condition instead of shownErrors.member, and @blur only comes with the challenge. /^M-\d{3}$/ requires exactly "M-" and three digits, with ^ and $ standing for the start and end of the text.
Exercise Challenge
Things can only be picked up and brought back on opening days. Add the message "You can pick things up on Wednesday or Saturday." for the first day and "You can bring things back on Wednesday or Saturday." for the last day. Also, the message for a field should appear as soon as someone leaves that field. new Date(date).getUTCDay() returns 3 for Wednesday and 6 for Saturday.
Solution
<script setup>import { computed, ref } from 'vue'
import items from '../data/items.json'import { daysBetween, formatDate, today } from './dates.js'
const item = items.find((entry) => entry.id === 1)
const member = ref('')const from = ref('')const to = ref('')const touched = ref({})const submitted = ref(false)const summary = ref('')
// Wednesday and Saturday, the days the shop is open, as numbers of getUTCDayconst OPENING_DAYS = [3, 6]
function isOpeningDay(date) { return OPENING_DAYS.includes(new Date(date).getUTCDay())}
// One message per field with a problem, an empty object means the request is validconst errors = computed(() => { const result = {} if (!/^M-\d{3}$/.test(member.value)) { result.member = 'The member number looks like M-117.' } if (from.value === '') { result.from = 'Choose the first day.' } else if (from.value < today()) { result.from = 'The first day is in the past.' } else if (!isOpeningDay(from.value)) { result.from = 'You can pick things up on Wednesday or Saturday.' } if (to.value === '') { result.to = 'Choose the last day.' } else if (from.value !== '' && to.value < from.value) { result.to = 'The last day is before the first.' } else if (from.value !== '' && daysBetween(from.value, to.value) > 7) { result.to = 'A loan lasts at most seven days.' } else if (!isOpeningDay(to.value)) { result.to = 'You can bring things back on Wednesday or Saturday.' } return result})
// A message appears once someone has left the field or sent the formconst shownErrors = computed(() => { const result = {} for (const [field, message] of Object.entries(errors.value)) { if (touched.value[field] || submitted.value) result[field] = message } return result})
function submit() { submitted.value = true if (Object.keys(errors.value).length > 0) return summary.value = `Reservation request from ${member.value} for ${item.name} from ${formatDate(from.value)} to ${formatDate(to.value)} recorded.`}</script>
<template> <h1>{{ item.name }}</h1> <p>{{ item.category }}</p> <h2>Request a reservation</h2> <form class="fields" novalidate @submit.prevent="submit"> <label for="member">Member number</label> <input id="member" v-model.trim="member" :aria-invalid="!!shownErrors.member" :aria-describedby="shownErrors.member ? 'member-error' : undefined" @blur="touched.member = true" > <p v-if="shownErrors.member" id="member-error" class="error">{{ shownErrors.member }}</p>
<label for="from">First day</label> <input id="from" v-model="from" type="date" :aria-invalid="!!shownErrors.from" :aria-describedby="shownErrors.from ? 'from-error' : undefined" @blur="touched.from = true" > <p v-if="shownErrors.from" id="from-error" class="error">{{ shownErrors.from }}</p>
<label for="to">Last day</label> <input id="to" v-model="to" type="date" :aria-invalid="!!shownErrors.to" :aria-describedby="shownErrors.to ? 'to-error' : undefined" @blur="touched.to = true" > <p v-if="shownErrors.to" id="to-error" class="error">{{ shownErrors.to }}</p>
<button type="submit">Send request</button> </form> <p class="summary" role="status">{{ summary }}</p></template>isOpeningDay checks the weekday, and that check comes last each time, so that the more important message appears first. @blur records the field that was left in touched, and shownErrors shows a message when its field was left or the form was sent. From Saturday to Saturday is exactly seven days, the longest loan the rules allow.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| Sending reloads the page, and all input is gone. | Put @submit.prevent on the form. |
value="3" in the HTML doesn't appear in the field. | v-model ignores the attribute. Set the initial value in the ref, ref(3). |
A text field for numbers delivers '3' instead of 3, and days + 1 gives '31'. | Use v-model.number or type="number", then Vue converts the value. |
<textarea>{{ note }}</textarea> doesn't keep field and ref in step. | Bind text areas with v-model too, interpolation doesn't work inside them. |
| The weekday is wrong in some time zones. | For dates without a time, use getUTCDay instead of getDay. |
| Errors are only a red border or sit in the placeholder. | Show the message as text next to the field and connect it with aria-describedby. |
| An AI assistant suggests a validation library for a small form. | For a few fields, a computed object of errors is enough. Libraries pay off for many and nested forms. |
Self-check
Section titled “Self-check”Self-check
- What is
v-modelshort for on a text field? - What do
.trim,.numberand.lazydo? - Why does the form need
@submit.prevent? - How do you connect an error message to its field so that a screen reader reads it out?
Answers
- For
:value="query"together with@input="query = $event.target.value". .trimremoves spaces at the edges,.numberconverts into a number, and.lazywrites only when the field is left.- Without
.prevent, the browser sends the form and reloads the page, and the state of the app is lost. - The message gets an id, and the field refers to it with
aria-describedby.aria-invalid="true"also says that the input isn't right.
Assignment
The first reservation request is recorded, and Martina Hostettler is pleased. While looking through the code, though, the volunteer noticed something. "App.vue is almost 180 lines long by now, and catalog, search and form are all crammed into it. We should split that up." That's exactly what part 4 does with components. Before that, use the competence grid to check whether you're ready for part 4.
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.