Forms and Validation

Intermediate
13 min

Forms and Validation

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.

Form State, Errors and Touched Fields

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:

vue
<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.

Rendering Errors Accessibly

vue
<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.

Extracting a useForm Composable

Once two forms share this logic, move it into a composable that takes initial values and rules:

javascript
// 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)] })`.

When to Use VeeValidate and Zod

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:

javascript
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.

Common mistakes

  • Showing errors on the first keystroke, which feels hostile; gate display on touched.
  • Storing errors in state and updating them manually; derive them with computed so they never go stale.
  • Relying only on a disabled button: pressing Enter still submits, so keep the check inside submit().
Quick Quiz
Question 1 of 2

Why should form errors be a `computed` property rather than a `ref` you update manually?

Key Takeaways

  • Keep three pieces of state: values, touched flags and computed errors; show errors only for touched fields or after a submit attempt.
  • Use novalidate, aria-invalid, aria-describedby and role="alert" for accessible error messages.
  • Extract repeated logic into a useForm composable with rule functions that return true or a message.
  • VeeValidate with Zod schemas scales to large forms and shares rules with the backend.
  • Always validate again on the server.

Next lesson: HTTP Requests with fetch and axios — load and send data, handle loading and error states, and cancel stale requests.

Forms and Validation - Vue.js | CodeYourCraft | CodeYourCraft