A good form validates as the user types without shouting errors before they finish, blocks submission until the data is valid, and explains problems accessibly. In this lesson you will build validation from first principles with reactive state and computed errors, extract it into a composable, and see when a library such as VeeValidate with a Zod schema pays off.
Three pieces of state cover most forms: the values, which fields the user has touched, and the errors. Errors are derived, so they belong in a computed:
<script setup>
import { reactive, computed } from 'vue'
const form = reactive({ email: '', password: '' })
const touched = reactive({ email: false, password: false })
const errors = computed(() => ({
email: !form.email
? 'Email is required'
: !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email) ? 'Enter a valid email' : '',
password: form.password.length < 8 ? 'At least 8 characters' : ''
}))
const isValid = computed(() => Object.values(errors.value).every(e => !e))
function submit() {
Object.keys(touched).forEach(k => (touched[k] = true))
if (!isValid.value) return
console.log('submitting', { ...form })
}
</script>Errors exist from the first render, but the template only shows them once a field is touched (on blur) or after a submit attempt: validate always, display on touch.
<template>
<form @submit.prevent="submit" novalidate>
<label for="email">Email</label>
<input
id="email"
v-model.trim="form.email"
type="email"
:aria-invalid="touched.email && !!errors.email"
aria-describedby="email-error"
@blur="touched.email = true"
/>
<p v-if="touched.email && errors.email" id="email-error" role="alert">
{{ errors.email }}
</p>
<button type="submit" :disabled="!isValid">Sign in</button>
</form>
</template>novalidate disables the browser's own bubbles so your messages are the only ones shown, while type="email" still improves mobile keyboards. aria-invalid and aria-describedby let screen readers announce the error, and role="alert" reads it as soon as it appears. Disabling the submit button is optional; many teams keep it enabled and mark all fields touched on click so the user learns what is missing.
Once two forms share this logic, move it into a composable that takes initial values and rules:
// src/composables/useForm.js
import { reactive, computed } from 'vue'
export function useForm(initial, rules) {
const values = reactive({ ...initial })
const touched = reactive({})
const errors = computed(() => {
const result = {}
for (const field in rules) {
const failing = rules[field].find(rule => rule(values[field], values) !== true)
result[field] = failing ? failing(values[field], values) : ''
}
return result
})
const isValid = computed(() => Object.values(errors.value).every(e => !e))
const touch = (field) => (touched[field] = true)
const touchAll = () => Object.keys(rules).forEach(touch)
const reset = () => Object.assign(values, initial)
return { values, touched, errors, isValid, touch, touchAll, reset }
}Rules are small functions that return true or a message, which makes them composable and testable: const required = (msg = 'Required') => v => (v !== '' && v != null) || msg and const minLength = n => v => v?.length >= n || \At least ${n} characters`. A component then calls useForm({ password: '' }, { password: [required(), minLength(8)] })`.
Hand-rolled validation is fine for a handful of forms. For many forms, nested objects, field arrays, or a schema shared with the backend, VeeValidate provides useForm, useField and matching components, and integrates with schema libraries:
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
const { handleSubmit, errors, defineField } = useForm({
validationSchema: toTypedSchema(z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters')
}))
})
const [email, emailAttrs] = defineField('email')
const onSubmit = handleSubmit(values => api.login(values))The Zod schema doubles as the TypeScript type of the form values and can be reused on the server, so rules are written once.
touched.computed so they never go stale.submit().Why should form errors be a `computed` property rather than a `ref` you update manually?
novalidate, aria-invalid, aria-describedby and role="alert" for accessible error messages.useForm composable with rule functions that return true or a message.Next lesson: HTTP Requests with fetch and axios — load and send data, handle loading and error states, and cancel stale requests.