Skip to content

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:

  1. The form asks for the member number, the first day and the last day.
  2. Things can only be picked up and brought back on Wednesday or Saturday, and a loan lasts at most seven days.
  3. Errors appear next to the field as soon as someone leaves it or sends the form, and a screen reader reads them out.
  4. 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, .number and .lazy,
  • send a form with @submit.prevent,
  • check input with a computed object of errors,
  • show error messages so that everyone notices them.

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.

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.

App.vue
<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>.

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.

App.vue
<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>

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.

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.

App.vue
<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 valid
const 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 form
const 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.

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

  1. What appears if you click "Suggest" right away? And what if you enter a name and leave the field with the Tab key?
  2. 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.
  3. Remove .prevent and send the form with valid values. What happens to the preview, and why?
  4. Remove novalidate and add the required attribute to the name. What does the browser show when you send the form with an empty name?

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.

Terminal window
npm install
npm test

The 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.

App.vue
<form class="fields" novalidate @submit.prevent="submit">
App.vue
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.

FieldRuleMessage
Member number"M-" and three digitsThe member number looks like M-117.
First daynot emptyChoose the first day.
First daynot before todayThe first day is in the past.
Last daynot emptyChoose the last day.
Last daynot before the first dayThe last day is before the first.
Last dayat most seven days after the firstA 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.

App.vue
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.

App.vue
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.

App.vue
<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
App.vue
<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 getUTCDay
const 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 valid
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.'
} 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 form
const 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.

MistakeWhat 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

  1. What is v-model short for on a text field?
  2. What do .trim, .number and .lazy do?
  3. Why does the form need @submit.prevent?
  4. How do you connect an error message to its field so that a screen reader reads it out?
Answers
  1. For :value="query" together with @input="query = $event.target.value".
  2. .trim removes spaces at the edges, .number converts into a number, and .lazy writes only when the field is left.
  3. Without .prevent, the browser sends the form and reloads the page, and the state of the app is lost.
  4. 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.