Skip to content
TutorialForms

Form state and validation by hand, no library

A form is three pieces of state: the values, the errors you derive from them, and which fields the person has visited. Build all three with reactive() and computed().

vuejs.school team1 May 20256 min readVue 3.5

Most forms do not need a form library. A sign-up form with three fields needs an object for the values, a computed object for the errors, a record of which fields the person has touched, and a submit function that talks to the server. That is one component with about sixty lines of script, and writing it once teaches you what the libraries do for you later.

You will build that component, SignUpForm.vue, in plain Vue 3.5 with <script setup lang="ts"> and nothing else installed. Its error messages wait until the person has left a field, and screen readers announce them.

Keep the values in one reactive object

Start with the shape of the data. A reactive() object holds all the fields, and each input binds to one property with v-model.

src/components/SignUpForm.vue
<script setup lang="ts">
import { reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  password: '',
})
</script>

<template>
  <form novalidate>
    <label for="name">Name</label>
    <input id="name" v-model.trim="form.name" type="text" autocomplete="name" />

    <label for="email">Email</label>
    <input id="email" v-model.trim="form.email" type="email" autocomplete="email" />

    <label for="password">Password</label>
    <input id="password" v-model="form.password" type="password" autocomplete="new-password" />

    <button type="submit">Create account</button>
  </form>
</template>

Two small decisions are already in here:

  • v-model.trim strips the spaces people leave around a name or an email, so the validation sees what they meant.
  • novalidate on the <form> turns off the browser's own error popups, because you are about to write better ones. The type="email" attribute still gives phones the right keyboard.

Use reactive() rather than three ref() calls because the form is one thing: you send it, reset it and validate it as a whole. A reactive object also keeps form.email a plain string with no .value, which reads well in script and template alike.

Every block below adds to this file: TypeScript goes in the <script setup> below form (merge each import into the vue import), and template snippets replace the matching lines.

Derive the errors from the values

Validation rules are a function of the current values and nothing else, which is exactly what computed() is for. Return an object with one message per invalid field and leave valid fields out:

import { computed } from 'vue'

type Field = keyof typeof form

const errors = computed(() => {
  const result: Partial<Record<Field, string>> = {}
  if (!form.name) result.name = 'Enter your name.'
  if (!form.email) result.email = 'Enter your email address.'
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) result.email = 'That email address does not look right.'
  if (form.password.length < 8) result.password = 'Use at least 8 characters.'
  return result
})

const isValid = computed(() => Object.keys(errors.value).length === 0)

Field is the union of the form's keys, 'name' | 'email' | 'password'. Partial<Record<Field, string>> says: an object whose keys are field names and whose values are strings, with every key optional. Because errors is a computed, it updates the moment a value changes and can never go out of sync with the form.

The email rule is deliberately loose. The real check is the confirmation email you send later, and a strict regular expression risks rejecting real addresses.

Show an error only after the person leaves the field

The first version usually looks like this:

src/components/SignUpForm.vueBreaks
<input id="email" v-model.trim="form.email" type="email" />
<p v-if="errors.email">{{ errors.email }}</p>

With every field written this way, all three errors are showing when the page opens, before anyone has typed a letter. Start typing an email and "does not look right" appears after the first character and stays until the address is complete. People read this as the form shouting at them, and some give up.

The fix is to remember which fields the person has finished with. A field counts as touched once it loses focus (the blur event). Show its error only then:

const touched = reactive<Partial<Record<Field, boolean>>>({})

function touch(field: Field) {
  touched[field] = true
}

function showError(field: Field) {
  return touched[field] ? errors.value[field] : undefined
}

Each input calls touch() on blur and shows showError() instead of errors:

src/components/SignUpForm.vueWorks
<input
  id="email"
  v-model.trim="form.email"
  type="email"
  @blur="touch('email')"
/>
<p v-if="showError('email')">{{ showError('email') }}</p>

Now the message appears when the person moves on from a field they got wrong. Once it is showing, it disappears the moment they fix the value, because errors is still live. That is the behaviour people expect: quiet while you type, honest when you leave.

Handle the submit and the server's reply

The submit handler does four things in order:

  1. Mark every field touched, so any remaining errors show.
  2. Stop if anything is invalid.
  3. Send the data, with submitting set while the request runs.
  4. Show the server's error, if it sends one.
import { ref } from 'vue'

const submitting = ref(false)
const serverError = ref('')

async function onSubmit() {
  for (const field of Object.keys(form) as Field[]) touch(field)
  if (!isValid.value) return

  submitting.value = true
  serverError.value = ''
  try {
    const response = await fetch('/api/sign-up', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    })
    if (!response.ok) {
      const body = await response.json().catch(() => ({}))
      serverError.value = body.message ?? 'Something went wrong. Try again.'
      return
    }
    // navigate or show a success message here
  } catch {
    serverError.value = 'Could not reach the server. Check your connection.'
  } finally {
    submitting.value = false
  }
}

fetch rejects only when the request itself fails, for example on a network error. An error status such as 409 arrives as a normal response, so the handler checks response.ok. It expects JSON like { "message": "That email is already registered." }, and the .catch(() => ({})) and ?? fall back to a general message when the reply is not JSON or has no message.

The finally block runs whether the request succeeded, failed or threw, which makes it the one place a submitting flag can be reset without forgetting a path.

Wire it up with @submit.prevent="onSubmit". The .prevent modifier calls event.preventDefault() for you, so the browser does not reload the page.

src/components/SignUpForm.vue
<form novalidate @submit.prevent="onSubmit">
  <p role="alert">{{ serverError }}</p>
  <button type="submit" :disabled="submitting">
    {{ submitting ? 'Creating account' : 'Create account' }}
  </button>
</form>

Disabling the button while submitting is true stops the double-click that creates two accounts. The empty <p role="alert"> is there on purpose; the next section explains why.

After a success, put the form back to its starting state so the person can see that it worked. Object.assign writes every field at once and keeps the object reactive, and emptying touched hides the errors that would otherwise reappear on the blank fields:

function resetForm() {
  Object.assign(form, { name: '', email: '', password: '' })
  for (const field of Object.keys(touched) as Field[]) delete touched[field]
}

Call it where the comment says to navigate or show a success message, and nowhere else: a form that resets itself after a failed submit throws away work.

Make the errors work with a screen reader

A sighted person sees the text under the field. A person using a screen reader hears the input's label and nothing else, unless you connect the two. Two attributes do it:

src/components/SignUpForm.vue
<input
  id="email"
  v-model.trim="form.email"
  type="email"
  autocomplete="email"
  :aria-invalid="showError('email') ? 'true' : undefined"
  :aria-describedby="showError('email') ? 'email-error' : undefined"
  @blur="touch('email')"
/>
<p v-if="showError('email')" id="email-error">{{ showError('email') }}</p>
  • aria-describedby points at the element whose text describes the input, so when focus lands on the field the screen reader reads the label and then the error.
  • aria-invalid="true" tells it the field is in an error state.

Both are bound with a ternary that returns undefined when there is no error, because Vue leaves out an attribute whose value is undefined. An empty string would render aria-describedby="". Give the name and password inputs the same pair, pointing at name-error and password-error.

A described error is read when focus reaches its field, not at the moment it appears. After a failed submit, focus stays where it was, usually on the button, so move it to the first field with an error, as the W3C forms tutorial suggests:

import { nextTick } from 'vue'

async function focusFirstError() {
  await nextTick()
  const first = (Object.keys(form) as Field[]).find((field) => errors.value[field])
  if (first) document.getElementById(first)?.focus()
}

Then change the early return in onSubmit to if (!isValid.value) return focusFirstError(). nextTick() waits for Vue to add the error and its aria-describedby, so both are read when focus arrives. The lookup relies on each input's id matching its field name, and on form listing the fields in page order.

The server error belongs to no field, so it uses role="alert" instead. That makes the paragraph a live region: a screen reader announces each change to its text, wherever focus is. The region should be on the page before its text changes, which is why the paragraph is always rendered: MDN's alert role page warns that an alert inserted with its text already inside, as v-if would do, is generally not announced.

The same page notes that setting the same message twice is not a change. That is what the serverError.value = '' line in onSubmit is for: emptying the text before each request lets a repeated error be announced again.

Keep each message next to its field, in the same order as the fields. Keep the red colour if you like, but do not rely on it alone. The text is the message.

When to reach for a library

Everything above fits in one component. Reach for a library when one of these becomes true:

  • The form has nested objects or arrays of rows, such as an order with many lines, and touched and error tracking has to follow items as they are added and removed.
  • You already have a schema for the data on the server and want to run the same rules in the browser.
  • Validation is asynchronous, for example checking that a username is free, and you need debouncing and cancellation.
  • You have more than a handful of forms and the same code is being copied from one to the next.

Later posts cover the libraries that handle those cases. Until then, this version is the one to understand, because the libraries are built from the same three pieces: values, derived errors and touched state.

Check it stuck3 questions from the Form state and validation by hand quiz.
Practice now
Was this helpful?

Keep reading