Forms in a component
In short: v-model in Vue binds a form field to the state both ways, so the state is the only source for checking and sending the form.
Problem
Typed, but empty
The buyer fills in the order and presses "Place order". The server answers that the name is required. And the field "Name" shows the name.
Console
Type a name, an email and an address, accept the terms and press "Place order". The field keeps "Maria Ivanova", but the request carries name: ''. The email and the address have v-model, and the name does not. The code sends the state, and the state does not know what is written in the field.
The idea
One truth: the state
In the DOM lesson the form kept the text itself, and the code read it on submit. In a component there is a second place: the state. If the two are not connected, each one says its own thing, as in the example.
The fix is that the field has no truth of its own. It shows the value from the state and on every key it tells the state the new text. Such a field is called controlled (a controlled input).
<script setup>const name = ref('');</script>
<template> <input id="name" v-model="name" /></template>v-model is a short form of both directions: :value="name" (from the state to the field) and @input="name = $event.target.value" (the way back). The input event comes on every key.
v-model knows the kind of field. So on a select and on a checkbox you write the same:
<select v-model="payment">…</select><input type="checkbox" v-model="agree" />On a checkbox agree is true or false, and on a select it is the value of the chosen option.
Now the state is the only source: what is in it is in the field, and the other way round. The button can be disabled by the state (!agree), and the request sends exactly what the buyer sees.
See it
See the circle
One letter goes the whole way: field, event, state, new render and the field again. Then the same field without a binding.
<script setup>const name = ref('');</script>
<template> <form @submit.prevent="submit"> <label for="name">Name</label> <input id="name" v-model="name" /> </form></template>Step by step, in words
- The form keeps the name in state: name = ''. The field on the page shows what the state says, and it says: empty.
- The buyer presses M. The browser puts the letter in the field and fires the input event.
- v-model listens for input. The text of the field now is “M”.
- v-model writes it into the ref: name.value = "M". That is the only place where the name lives.
- The ref changed, so Vue runs the template again. v-model gives the field the value of name: “M”.
- The field gets its value from the state. It is the same letter, but now the state decides what the field shows.
- Every key goes around the whole circle: field, event, state, new render, field. One source of truth: the state.
- Now the same field without v-model. The field works, but nobody listens to it and nobody gives it a value.
- The buyer types “Maria”. The letters stay in the DOM. The state still says: name = ''.
- On submit the code sends the state: { name: '' }. The server answers “Name is required”, while the field shows “Maria”.
- The rule: every field has v-model, a value from the state and an event back to it. Then the field and the state cannot differ.
The circle looks like a needless detour: the letter is in the field without it. But because of it the state and the field cannot differ.
Try it
Bind the fields
The field Name, the select Payment and the checkbox I accept the terms are not bound to the state. Bind all three. The Place order button is disabled until the terms are accepted. The check types a name, an email and an address, chooses Card on delivery, checks and unchecks the box, and then orders. It expects Order no. 61 is placed. and a request with the typed name and payment: 'card'.
Console
Solution
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
async function submit() {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Order no. {{ orderId }} is placed.</p>
<form v-else novalidate @submit.prevent="submit">
<p>In the cart: White bread × 2, Fresh milk × 1</p>
<label for="name">Name</label>
<input id="name" v-model="name" />
<label for="email">Email</label>
<input id="email" type="email" v-model="email" />
<label for="address">Address</label>
<input id="address" v-model="address" />
<label for="payment">Payment</label>
<select id="payment" v-model="payment">
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
I accept the terms
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Place order
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
All three fields get v-model: v-model="name", v-model="payment" on the <select> and v-model="agree" on the checkbox.
The check catches :value="name" without v-model: the letters show, but name stays empty and goes out empty. It also catches :checked="agree": the box looks checked, but agree stays false and the button does not get enabled.
Try it
Fill in
What goes in the blank so that the checkbox is bound?
<script setup>
const agree = ref(false);
</script>
<template>
<input type="checkbox" ="agree" />
</template>v-model: on a checkbox it binds checked and the change event, and agree gets true or false.Try it
Find the bug
Pick the line with the bug.
:value="name" is only the direction from the state to the field. The letters stay in the DOM, name stays empty, and on the next render Vue puts the field back to it. It needs v-model="name".The idea
A check next to the field
The server checks every order. But the buyer should not wait for an answer to find out they forgot the address. So the form checks by itself too, before it sends.
- When. On pressing "Place order". If there are errors, no request goes out.
- What. A function
check()returns an object with the errors per field:{ name: 'Enter your name', email: … }. An empty object means "all is fine". It is kept in the stateerrors. - Where. Next to the field, not in a common list at the top. The buyer sees which field to fix.
- For everyone. A screen reader does not see that the text is red. So the field gets
aria-invalid="true"(the reader says "invalid"), and the message gets anidthat the field points to witharia-describedby. When the focus comes to the field, the reader reads the message too.
<input id="email" v-model="email" :aria-invalid="!!errors.email" :aria-describedby="errors.email ? 'email-error' : undefined"/><p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>The form has novalidate. Without it the browser stops the submit with its own bubbles before your code is reached, and the two checks get in each other's way.
The server checks again. The check in the browser is for the buyer's convenience. It does not protect the shop: the code of the page is with the buyer, and a request can come without it. The lesson "Untrusted input" explains why.
Try it
Check before sending
Add a check before sending. An empty name gives Enter your name, an email without @ gives The email must contain @, an empty address gives Enter a delivery address. Each message is next to its field, in an element with the id name-error, email-error and address-error, and the field has aria-invalid=true and aria-describedby pointing to it. With errors no request goes out. The check accepts the terms, orders an empty form, then with an email without @, and at last with everything right.
Console
Solution
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
const errors = ref({});
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Enter your name';
if (!email.value.includes('@')) found.email = 'The email must contain @';
if (!address.value.trim()) found.address = 'Enter a delivery address';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Order no. {{ orderId }} is placed.</p>
<form v-else novalidate @submit.prevent="submit">
<p>In the cart: White bread × 2, Fresh milk × 1</p>
<label for="name">Name</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Address</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Payment</label>
<select id="payment" v-model="payment">
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
I accept the terms
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Place order
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Add const errors = ref({}) and a function check() that returns an object with the errors. In submit first const found = check(); errors.value = found;, and if found has keys, return before fetch. Next to every field: :aria-invalid="!!errors.name", :aria-describedby="errors.name ? 'name-error' : undefined" and <p v-if="errors.name" id="name-error">…</p>.
The check catches a message without aria-invalid and aria-describedby: on the screen it looks the same, but for a screen reader the field is fine. It also catches messages after which the request goes out anyway: then a moment later the server's answer shows up too.
The idea
Sending and the server's answer
The request takes time, and the buyer does not know whether the click got through. If they click again, a second order goes out. So the state also has sending:
- Before
fetch,sendingbecomestrue. The button isdisabled, and next to it it says "Sending…". - After the answer,
sendingbecomesfalse, whatever came back. - 201: the body is
{ id }. The form is replaced with "Order no. 61 is placed." - 400: the body is
{ errors: { name, email, address, items } }, one message per field. They go into the sameerrors, so they show in the same places next to the fields.itemshas no field: it shows next to the line with the cart.
sending.value = true;const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, /* … */ items: ITEMS }),});const data = await res.json();sending.value = false;if (res.ok) orderId.value = data.id;else errors.value = data.errors;The check in the browser is simpler than the server's: it only looks for "@". The email maria@shop passes it, and the server sends it back with "The email is not valid". So the form must also be able to show an error it did not know about.
More precisely, if you are curious
Modifiers of v-model. v-model.trim removes the spaces at the start and the end, v-model.number turns the text into a number (for type="number"), and v-model.lazy updates on change (when the field loses focus), not on every key.
:value without an event. The field shows name, the letters go into the DOM, but name does not change. When the component renders again for another reason (say, an error message appears), Vue puts the field back to the value of name, and what was typed disappears.
One object instead of five refs. You can also have const form = reactive({ name: '', email: '', … }) with v-model="form.name". Vue watches the properties of the object, so v-model works on each of them.
v-model on a component is :modelValue down and @update:modelValue up, as in the lesson about lifting state up. That is how field components are made (<MoneyInput v-model="price" />).
When to check. On submit is the simplest. A check on every key shows errors before the buyer has finished ("the email is not valid" after the first letter). The common answer is: the first check on submit or on leaving the field (blur), and after that on every key, so the error disappears as soon as it is fixed.
Focus on an error. After a failed submit it helps to move the focus to the first field with an error. Then the keyboard and the screen reader get straight to the problem.
The check cannot see the code. The tasks here check behaviour: what is shown, what is disabled, what is sent. They do not see whether the check is in a separate function or how the variables are named.
Exercise
The order, all the way
Now the form checks in the browser, but it does not show that it is sending, and it puts the server's errors into one paragraph. Make it a real order: while the request runs, the Place order button is disabled and there is a text Sending… (with role=status). On a 400 answer the errors from data.errors go next to the fields, in the same places as yours (email-error and aria-invalid on the email), and the error for items next to the line with the cart. The check types the email maria@shop: it passes your check, but the server sends it back with The email is not valid. Then it adds .bg and the order goes through: Order no. 61 is placed.
Console
Solution
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Checkout</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const errors = ref({});
const sending = ref(false);
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Enter your name';
if (!email.value.includes('@')) found.email = 'The email must contain @';
if (!address.value.trim()) found.address = 'Enter a delivery address';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
sending.value = true;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
sending.value = false;
if (res.ok) orderId.value = data.id;
else errors.value = data.errors;
}
</script>
<template>
<p v-if="orderId" role="status">Order no. {{ orderId }} is placed.</p>
<form v-else novalidate @submit.prevent="submit">
<p>In the cart: White bread × 2, Fresh milk × 1</p>
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<label for="name">Name</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Address</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Payment</label>
<select id="payment" v-model="payment">
<option value="cash">Cash on delivery</option>
<option value="card">Card on delivery</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
I accept the terms
</label>
<p v-if="sending" role="status">Sending…</p>
<button type="submit" :disabled="!agree || sending">
Place order
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Add const sending = ref(false). Around fetch: sending.value = true before and sending.value = false after the answer. The button is :disabled="!agree || sending", and <p v-if="sending" role="status">Sending…</p> stands above it. On an error, instead of the common paragraph, errors.value = data.errors: the server's messages go into the same errors and show next to the fields. The problem paragraph is no longer needed.
The check catches a button that stays enabled while it says "Sending…", and a button that never gets enabled after the answer (a forgotten false).
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The first component · Conditions: empty, error, loading · State · Data from an API in a component
- Continues in: Shared state · The shop front
- Tasks on this topic: Effects, data and forms