Form state and validation by hand
Keep a form's values, touched fields, errors, pending request and server answer in plain Vue, with every error linked to its input, so you know what a form library does for you.
What you'll learn
When you finish, you can do these.- 1Keep form values in one object and errors up to date
- 2Show each error with its input once the person leaves it
- 3Send one request at a time and show the server's error
- 4Spot and fix a reset that stops the errors from updating
Picture a sign-up form with three fields: Name, Email and Age. Besides what the person types, it has to know which fields they have finished with, which are wrong and why, whether a request is running, and what the server answered. Get that wrong and the form complains about an empty email before anyone has typed, sends twice on a double click, or shows the server's error far from its field.
The Code tab holds that form, built by hand: App.vue is the page, Field.vue draws one input with its label and error, and api.ts fakes the server. By the end you can add a field with its own rule, and know what a form library does for you. You need one idea from Forms and inputs, in the Beginner track: v-model, which keeps a form field and a value in step both ways.
One reactive object holds the values
First come the values, and the error each one earns.
In Forms and inputs, each field had its own ref, a box for one value. Here the values share one object made with reactive(), which tracks every property like a ref (Reactivity basics). So v-model="form.email" reads naturally, one Object.assign() resets them all, and { ...form } copies them to send.
const form = reactive({ name: '', email: '', age: '' as number | '' })
The type number | '' lets age hold what a type="number" input gives: 15 after typing 15, the empty string once cleared.
Errors are not stored next to the values. A computed, a value Vue works out again when what it reads changes, gives one message per field, or an empty string when the field is fine, so an error is never out of date.
const errors = computed(() => ({
name: form.name.trim().length < 2 ? 'Enter at least 2 characters' : '',
email: /^\S+@\S+\.\S+$/.test(form.email) ? '' : 'Enter a valid email',
age: form.age === '' || form.age >= 13 ? '' : 'You must be 13 or older',
}))
const isValid = computed(() => Object.values(errors.value).every(e => !e))
The email rule is a regular expression, a text pattern: text, an @, text, a dot, text. isValid is true when every message is empty.
Show an error when the person is done, not while they type
An error needs a place, next to its input, and a moment, once the person is done.
Field.vue draws one input with its label and its message, the prop error. An id from useId(), unique to each Field, ties the three together (Accessible components).
Two attributes speak to a screen reader, the software that reads a page aloud. aria-invalid says the value is wrong, and aria-describedby points at the message, so the two are read together:
<input
:id="id"
v-model="model"
:type="type"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="error ? `${id}-error` : undefined"
@blur="emit('blur')"
/>
<p v-if="error" :id="`${id}-error`" class="error">{{ error }}</p>
The message shows only while there is an error. novalidate on the <form> keeps the browser's own error bubbles out of the way.
As for when: type the first letter of your name, and errors.name already says "Enter at least 2 characters". Showing that at once feels like being told off. Mark a field touched once the person leaves it, a blur in browser terms, and show its error only then.
- Type
- Blurtouched
- Computed errors
- Submittouch all
- Pending
- Sent
Two lines of App.vue do it:
const touched = reactive<Partial<Record<Key, boolean>>>({})
const message = (key: Key) => (touched[key] ? errors.value[key] || serverErrors.value[key] || '' : '')
Key is a field name: name, email or age. touched gets true under a field's name once the field is left. message() gives what a field shows: nothing until it is touched, then its error, or the server's error from the Sending section.
In App.vue, each Field gets its message as error, and sets its touched mark when it emits blur, as its input loses focus:
<Field v-model="form.name" label="Name" :error="message('name')" @blur="touched.name = true" />
Leave Name after one letter: its message appears.
Submit marks every field touched, so Sign up on an untouched form shows every problem at once, then stops if anything is invalid:
async function submit() {
if (pending.value) return
for (const key of Object.keys(form) as Key[]) touched[key] = true
serverErrors.value = {}
if (!isValid.value) {
focusFirstInvalid()
return
}
// then send the request
}
Stopping silently leaves a keyboard user on the button with no idea where the problem is. focusFirstInvalid() waits with nextTick() until Vue has rendered the aria-invalid attributes, then focuses the first field marked with it inside formEl: the <form> element itself, from useTemplateRef(), not the form object. The screen-reader attribute is what the search looks for, so no list of inputs is needed.
async function focusFirstInvalid() {
await nextTick()
formEl.value?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}
Click inside the preview once to see the focus move.
Sending: the pending flag and the server's answer
The last piece of state is the request: running or not, and the server's answer.
The starter fakes the server in api.ts. signUp() counts each call in requests, shown under the form as Requests sent, waits 800 ms, and throws an ApiError naming a field when the email is taken@example.com. A real server answers that case with status 422, the field and the reason.
Once every field is valid, submit() sets pending and sends:
pending.value = true
try {
await signUp({ ...form })
sent.value = true
} catch (error) {
if (!(error instanceof ApiError)) throw error
serverErrors.value = { [error.field]: error.message }
focusFirstInvalid()
} finally {
pending.value = false
}
The button reads Sending for 800 ms. Then sent swaps the form for a thank-you line with a Start over button that calls reset() to empty the values and the touched marks.
pending does two jobs. In the template it disables the button and changes its label. At the top of submit() it returns while a request runs, whatever started the call. finally clears it on success and failure alike, so the form never sticks on Sending.
catch handles only an ApiError and throws any other error again, so a real bug is not hidden.
The message goes under its field in serverErrors, as { email: 'That email already has an account' }, and message() shows it like a computed error, with the same aria-invalid and focus. It stays until the next submit, because only the server can say whether it is gone.
What goes wrong
Two mistakes look fine until someone presses Start over or double-clicks Sign up.
The first is a reset that writes a fresh reactive() object into the variable, where the starter's reset() uses Object.assign():
let form = reactive({ name: '', email: '', age: '' as number | '' })
function reset() {
form = reactive({ name: '', email: '', age: '' }) // a second object
sent.value = false
}
With const, as in the starter, Start over stops with an error such as Assignment to constant variable. With let the inputs clear, but errors still tracks the first object and never hears about the second: after Start over, a one-letter name gets no message and the form sends. Change the values inside the object instead, and the message comes back:
function reset() {
Object.assign(form, { name: '', email: '', age: '' })
sent.value = false
}
The second is a submit with no pending guard, so nothing stops a second call:
async function submit() {
for (const key of Object.keys(form) as Key[]) touched[key] = true
if (!isValid.value) return
await signUp({ ...form })
sent.value = true
}
Double-click Sign up and Requests sent reads 2: two accounts, two welcome emails. The guard at the top of submit() and the disabled button fix it together:
async function submit() {
if (pending.value) return
// touch every field and stop if invalid, as in the starter
pending.value = true
try {
await signUp({ ...form })
sent.value = true
} finally {
pending.value = false
}
}
A double-click now sends once: Requests sent reads 1.
What to remember
- Values live in one
reactiveobject. Errors are acomputedover it, never stored beside it. aria-invalidandaria-describedbytie each message to its input. A message shows onceblurmarks its field touched; submit touches every field and focuses the first[aria-invalid="true"].pendingguardssubmit()and disables the button, andfinallyclears it. A server error is stored under its field name and shown like any other.- A reset changes the values inside the object with
Object.assign(). A freshreactive()leaves the errors following the old one.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
password: ''toform, the rulepassword: form.password.length < 8 ? 'At least 8 characters' : ''toerrors, and a<Field v-model="form.password" label="Password" type="password" :error="message('password')" @blur="touched.password = true" />above the button. Click into Password and out again: At least 8 characters appears under it. - Sign up as Asha with
taken@example.com, then type one more letter in Email: the server's message stays. Importwatch, which runs code when a value changes, and addwatch(() => form.email, () => delete serverErrors.value.email)underformEl: now the message disappears at the first keystroke. - Delete the
if (pending.value) returnline and the:disabled="pending"attribute, fill in Asha andasha@example.com, and double-click Sign up: Requests sent reads 2. Put the guard line back and leave the button enabled: a double-click now reads 1.