---
title: "Form state and validation by hand, no library · vuejs.school"
description: "A sign-up form with reactive state, computed errors, touched tracking on blur, server errors on submit, and messages a screen reader announces."
canonical_url: "https://vuejs.school/community/blog/form-state-and-validation-by-hand"
meta:
  "og:description": "A sign-up form with reactive state, computed errors, touched tracking on blur, server errors on submit, and messages a screen reader announces."
  "og:image": "https://vuejs.school/_og/d/a_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5%2Cdescription_~QSBzaWduLXVwIGZvcm0gd2l0aCByZWFjdGl2ZSBzdGF0ZSwgY29tcHV0ZWQgZXJyb3JzLCB0b3VjaGVkIHRyYWNraW5nIG9uIGJsdXIsIHNlcnZlciBlcnJvcnMgb24gc3VibWl0LCBhbmQgbWVzc2FnZXMgYSBzY3JlZW4gcmVhZGVyIGFubm91bmNlcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mb3JtLXN0YXRlLWFuZC12YWxpZGF0aW9uLWJ5LWhhbmQi%2Cs_OfLiO_6VZCdeRacl.png"
  "og:image:alt": "Form state and validation by hand, no library · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Form state and validation by hand, no library · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5%2Cdescription_~QSBzaWduLXVwIGZvcm0gd2l0aCByZWFjdGl2ZSBzdGF0ZSwgY29tcHV0ZWQgZXJyb3JzLCB0b3VjaGVkIHRyYWNraW5nIG9uIGJsdXIsIHNlcnZlciBlcnJvcnMgb24gc3VibWl0LCBhbmQgbWVzc2FnZXMgYSBzY3JlZW4gcmVhZGVyIGFubm91bmNlcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mb3JtLXN0YXRlLWFuZC12YWxpZGF0aW9uLWJ5LWhhbmQi%2Cs_OfLiO_6VZCdeRacl.png"
  "twitter:image:alt": "Form state and validation by hand, no library · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.vue**Breaks***

```
<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.vue**Works***

```
<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](https://www.w3.org/WAI/tutorials/forms/notifications/) 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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/alert_role) 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 stuck**3 questions from the Form state and validation by hand quiz.

[**Practice now**](https://vuejs.school/learn/forms/form-state-and-validation-by-hand?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 6 min read**Accessible form errors: aria-describedby, live regions and focus**](https://vuejs.school/community/blog/accessible-form-errors) [Tutorial · 6 min read**Multi-step forms with a Pinia store**](https://vuejs.school/community/blog/multi-step-forms-with-a-pinia-store)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-01","description":"A sign-up form with reactive state, computed errors, touched tracking on blur, server errors on submit, and messages a screen reader announces.","name":"Form state and validation by hand, no library","url":"https://vuejs.school/community/blog/form-state-and-validation-by-hand","breadcrumb":{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/form-state-and-validation-by-hand"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-05-01","description":"A sign-up form with reactive state, computed errors, touched tracking on blur, server errors on submit, and messages a screen reader announces.","headline":"Form state and validation by hand, no library","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5%2Cdescription_~QSBzaWduLXVwIGZvcm0gd2l0aCByZWFjdGl2ZSBzdGF0ZSwgY29tcHV0ZWQgZXJyb3JzLCB0b3VjaGVkIHRyYWNraW5nIG9uIGJsdXIsIHNlcnZlciBlcnJvcnMgb24gc3VibWl0LCBhbmQgbWVzc2FnZXMgYSBzY3JlZW4gcmVhZGVyIGFubm91bmNlcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mb3JtLXN0YXRlLWFuZC12YWxpZGF0aW9uLWJ5LWhhbmQi%2Cs_OfLiO_6VZCdeRacl.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/form-state-and-validation-by-hand#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Form state and validation by hand, no library","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5%2Cdescription_~QSBzaWduLXVwIGZvcm0gd2l0aCByZWFjdGl2ZSBzdGF0ZSwgY29tcHV0ZWQgZXJyb3JzLCB0b3VjaGVkIHRyYWNraW5nIG9uIGJsdXIsIHNlcnZlciBlcnJvcnMgb24gc3VibWl0LCBhbmQgbWVzc2FnZXMgYSBzY3JlZW4gcmVhZGVyIGFubm91bmNlcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mb3JtLXN0YXRlLWFuZC12YWxpZGF0aW9uLWJ5LWhhbmQi%2Cs_OfLiO_6VZCdeRacl.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5IMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~Rm9ybSBzdGF0ZSBhbmQgdmFsaWRhdGlvbiBieSBoYW5kLCBubyBsaWJyYXJ5%2Cdescription_~QSBzaWduLXVwIGZvcm0gd2l0aCByZWFjdGl2ZSBzdGF0ZSwgY29tcHV0ZWQgZXJyb3JzLCB0b3VjaGVkIHRyYWNraW5nIG9uIGJsdXIsIHNlcnZlciBlcnJvcnMgb24gc3VibWl0LCBhbmQgbWVzc2FnZXMgYSBzY3JlZW4gcmVhZGVyIGFubm91bmNlcy4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy9mb3JtLXN0YXRlLWFuZC12YWxpZGF0aW9uLWJ5LWhhbmQi%2Cs_OfLiO_6VZCdeRacl.png"}]}
```