Skip to content
wannacode

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.

Vue

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
  1. The form keeps the name in state: name = ''. The field on the page shows what the state says, and it says: empty.
  2. The buyer presses M. The browser puts the letter in the field and fires the input event.
  3. v-model listens for input. The text of the field now is “M”.
  4. v-model writes it into the ref: name.value = "M". That is the only place where the name lives.
  5. The ref changed, so Vue runs the template again. v-model gives the field the value of name: “M”.
  6. The field gets its value from the state. It is the same letter, but now the state decides what the field shows.
  7. Every key goes around the whole circle: field, event, state, new render, field. One source of truth: the state.
  8. Now the same field without v-model. The field works, but nobody listens to it and nobody gives it a value.
  9. The buyer types “Maria”. The letters stay in the DOM. The state still says: name = ''.
  10. On submit the code sends the state: { name: '' }. The server answers “Name is required”, while the field shows “Maria”.
  11. 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'.

Vue

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>

Try it

Find the bug

The buyer types their name and it shows, but the form says it is empty. The email works. Which line is to blame?

Pick the line with the bug.

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.

  1. When. On pressing "Place order". If there are errors, no request goes out.
  2. 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 state errors.
  3. Where. Next to the field, not in a common list at the top. The buyer sees which field to fix.
  4. 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 an id that the field points to with aria-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.

Vue

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:

  1. Before fetch, sending becomes true. The button is disabled, and next to it it says "Sending…".
  2. After the answer, sending becomes false, whatever came back.
  3. 201: the body is { id }. The form is replaced with "Order no. 61 is placed."
  4. 400: the body is { errors: { name, email, address, items } }, one message per field. They go into the same errors, so they show in the same places next to the fields. items has 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.

Vue

Test

Check yourself

1. The field "Name" shows "Maria", and the state name is empty. Which one goes to the server?
2. The field is <input :value="name" />, with no v-model. What happens when the buyer types?
3. What does v-model give on a checkbox (type="checkbox")?
4. The check in the browser says the email is fine. Should the server check it again?
5. Why is the "Place order" button disabled while the request is running?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·