Forms at Scale & Validation
In Forms & v-model you validated a small form by hand. A real app has dozens of forms: sign-up, address, KYC, checkout, admin settings. If every form writes its own error logic, they all behave slightly differently, some forget accessibility, and fixing one bug means fixing it twenty times.
In this lesson you will build the two pieces that make forms scale: a reusable field component that handles labels, errors and accessibility, and a useForm() composable that handles values, rules, touched state, submitting and server errors. Then you will see how schema libraries such as Zod and form libraries such as VeeValidate do the same job in bigger projects.
What a good form does
Before writing code, agree on the behaviour. A professional form:
- Shows an error only after the user has left a field (on blur) or pressed Submit, not while they are still typing their first letter.
- Once a field has an error, re-checks it on every key press so the message disappears as soon as it is fixed.
- On Submit, checks every field and moves keyboard focus to the first invalid one.
- Disables the Submit button while the request is running, so nobody pays twice.
- Shows errors that come back from the server (for example "mobile number already registered") next to the right field.
- Connects each error to its input with
aria-invalidandaria-describedby, so screen reader users hear it.
Build a reusable form step by step
Rohan is building the sign-up form for a coaching institute in Rohtak. Follow the same steps:
- Write small rule functions. Each rule takes a value and returns an error message, or an empty string when the value is fine.
required("Enter your name")andpattern(/^[6-9]\d{9}$/, "...")are rule factories that create such functions. - Create
useForm(initialValues, rules). It keepsvalues,errorsandtouchedin reactive objects, plus asubmittingref. - Add
validateField(name). It runs that field's rules in order and stores the first message. - Add a
field(name)helper. It returns everything a field component needs: the value, the error, an update handler that re-validates touched fields, and a blur handler. You spread it onto the component withv-bind. - Add
handleSubmit(callback). It marks every field touched, validates all of them, focuses the first invalid input, and only then calls your callback with a copy of the values. - Build
BaseField. It renders the label, the input and the error, generates a unique id withuseId(), and links them with ARIA attributes. - Map server errors. When the API rejects the data, copy its field errors into
errorswithsetErrors().
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Form at scale</title>
<script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
<style>
body { font-family: system-ui, sans-serif; margin: 16px; max-width: 420px; }
.field { display: grid; gap: 3px; margin-bottom: 12px; }
input { padding: 7px; border: 1px solid #a0aec0; border-radius: 4px; }
input[aria-invalid=true] { border-color: #c53030; }
.error { color: #c53030; } .hint { color: #4a5568; }
.done { background: #f0fff4; padding: 8px; }
</style>
</head>
<body>
<div id="app"></div>
<script>
const { createApp, reactive, ref, nextTick, useId } = Vue;
// Step 1: rule factories
const required = (msg) => (v) => (String(v ?? "").trim() ? "" : msg);
const pattern = (re, msg) => (v) => (!v || re.test(v) ? "" : msg);
const minLength = (n, msg) => (v) => (!v || v.length >= n ? "" : msg);
const sameAs = (other, msg) => (v, all) => (v === all[other] ? "" : msg);
// Steps 2-5 and 7: the composable
function useForm(initial, rules) {
const values = reactive({ ...initial });
const errors = reactive({});
const touched = reactive({});
const submitting = ref(false);
function validateField(name) {
for (const rule of rules[name] || []) {
const message = rule(values[name], values);
if (message) { errors[name] = message; return false; }
}
errors[name] = "";
return true;
}
function field(name) {
return {
modelValue: values[name],
error: errors[name] || "",
"onUpdate:modelValue": (v) => { values[name] = v; if (touched[name]) validateField(name); },
onBlur: () => { touched[name] = true; validateField(name); }
};
}
function setErrors(map) { Object.assign(errors, map); }
function handleSubmit(callback) {
return async () => {
const names = Object.keys(rules);
names.forEach((n) => (touched[n] = true));
const ok = names.map(validateField).every(Boolean);
if (!ok) {
await nextTick();
document.querySelector("[aria-invalid=true]")?.focus();
return;
}
submitting.value = true;
try { await callback({ ...values }); } finally { submitting.value = false; }
};
}
return { values, errors, submitting, field, setErrors, handleSubmit };
}
// Step 6: the field component
const BaseField = {
inheritAttrs: false,
props: { label: String, modelValue: String, error: String, hint: String, type: { type: String, default: "text" } },
emits: ["update:modelValue", "blur"],
setup() { return { id: useId() }; },
template: `
<div class="field">
<label :for="id">{{ label }}</label>
<input :id="id" :type="type" :value="modelValue" v-bind="$attrs"
:aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? id + '-error' : (hint ? id + '-hint' : null)"
@input="$emit('update:modelValue', $event.target.value)" @blur="$emit('blur')">
<small v-if="hint && !error" :id="id + '-hint'" class="hint">{{ hint }}</small>
<small v-if="error" :id="id + '-error'" class="error">{{ error }}</small>
</div>`
};
// A pretend API: this number is "already registered"
function registerStudent(data) {
return new Promise((resolve, reject) => setTimeout(() => {
if (data.mobile === "9999999999") reject({ fieldErrors: { mobile: "This mobile number is already registered" } });
else resolve({ id: 501 });
}, 700));
}
createApp({
components: { BaseField },
setup() {
const done = ref("");
const form = useForm(
{ name: "", mobile: "", pincode: "", password: "", confirm: "" },
{
name: [required("Enter your full name"), minLength(2, "Name is too short")],
mobile: [required("Enter your mobile number"), pattern(/^[6-9]\d{9}$/, "Enter a 10-digit Indian mobile number")],
pincode: [required("Enter your pincode"), pattern(/^\d{6}$/, "Pincode has 6 digits")],
password: [required("Choose a password"), minLength(8, "Use at least 8 characters")],
confirm: [sameAs("password", "Passwords do not match")]
}
);
const onSubmit = form.handleSubmit(async (data) => {
try {
const res = await registerStudent(data);
done.value = `Welcome, ${data.name}! Your student id is ${res.id}.`;
} catch (e) {
if (e.fieldErrors) form.setErrors(e.fieldErrors);
}
});
return { form, onSubmit, done };
},
template: `
<p v-if="done" class="done" role="status">{{ done }}</p>
<form v-else novalidate @submit.prevent="onSubmit">
<BaseField label="Full name" v-bind="form.field('name')" autocomplete="name" />
<BaseField label="Mobile" v-bind="form.field('mobile')" inputmode="numeric" maxlength="10" autocomplete="tel-national" hint="Try 9999999999 to see a server error" />
<BaseField label="Pincode" v-bind="form.field('pincode')" inputmode="numeric" maxlength="6" autocomplete="postal-code" />
<BaseField label="Password" type="password" v-bind="form.field('password')" autocomplete="new-password" />
<BaseField label="Confirm password" type="password" v-bind="form.field('confirm')" autocomplete="new-password" />
<button :disabled="form.submitting.value">{{ form.submitting.value ? "Registering..." : "Register" }}</button>
</form>`
}).mount("#app");
</script>
</body>
</html>Press Register with an empty form: every error appears and focus jumps to "Full name". Fix a field and its error disappears as you type. Notice form.submitting.value in the template: form is a plain object holding a ref, so the ref is not unwrapped automatically there. The page that uses the form contains only labels and rules; all behaviour lives in two reusable pieces.
Schema validation with Zod
Rules written as arrays work, but larger teams often describe the whole form as one schema: an object that declares the type and constraints of every field. The same schema can validate the form in the browser and the request body in a Node.js API, so the rules never drift apart. Zod is a popular schema library (Valibot and Yup are others):
import { z } from "zod";
const signupSchema = z.object({
name: z.string().trim().min(2, "Enter your full name"),
mobile: z.string().regex(/^[6-9]\d{9}$/, "Enter a 10-digit mobile number"),
email: z.email("Enter a valid email"),
pincode: z.string().regex(/^\d{6}$/, "Pincode has 6 digits")
});
const result = signupSchema.safeParse({ name: " A ", mobile: "98765", email: "x@", pincode: "124001" });
console.log(result.success);
console.log(z.flattenError(result.error).fieldErrors);false
{
name: [ 'Enter your full name' ],
mobile: [ 'Enter a 10-digit mobile number' ],
email: [ 'Enter a valid email' ]
}safeParse() never throws; it returns { success, data } or { success, error }. When it succeeds, data holds the cleaned values (here the name is trimmed). To use a schema in your own useForm(), call safeParse(values) in handleSubmit, then copy the first message of each field into errors. The examples use Zod 4, where z.email() is a top-level function; Zod 3 code writes z.string().email().
Form libraries: VeeValidate and friends
For very large apps you may prefer a ready-made library instead of your own composable. The most widely used in Vue is VeeValidate. Its useForm() takes a schema and returns helpers much like the ones you just built:
<script setup>
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
const { errors, defineField, handleSubmit } = useForm({
validationSchema: toTypedSchema(z.object({
email: z.email("Enter a valid email"),
city: z.string().min(2, "Enter your city")
}))
});
const [email, emailAttrs] = defineField("email");
const [city, cityAttrs] = defineField("city");
const onSubmit = handleSubmit((values) => console.log("Send to API", values));
</script>
<template>
<form @submit="onSubmit">
<input v-model="email" v-bind="emailAttrs" type="email">
<span>{{ errors.email }}</span>
<input v-model="city" v-bind="cityAttrs">
<span>{{ errors.city }}</span>
<button>Save</button>
</form>
</template>| Tool | What it is | Choose it when |
|---|---|---|
Your own useForm() | About 60 lines you fully control | A few forms with simple rules |
| VeeValidate | Composition API form state plus schema support | Many forms, complex rules, field arrays |
| FormKit | A form framework that also renders inputs, labels and errors | You want ready-made, styled inputs quickly |
| Zod, Valibot, Yup | Schema libraries, not tied to Vue | Sharing rules between front end and back end |
Whichever you pick, always validate again on the server. Browser checks are for a good user experience; anyone can bypass them by calling your API directly.
Common mistakes
- Showing errors on the first key press. It feels like the form is shouting. Validate on blur and on submit; re-validate on input only after a field is touched.
- Errors that are only red text. Add
aria-invalidand link the message witharia-describedby; colour alone is not enough. - Placeholders instead of labels. Placeholders disappear when typing and often have poor contrast. Always use a
<label>. - Allowing double submits. Disable the button while
submittingis true. - Trusting browser validation alone. The server must validate the same data.
- Ignoring server errors. Map them to fields so users know exactly what to fix.
Key points
- Split form logic into a reusable field component and a
useForm()composable. - Validate on blur and submit, re-check touched fields on input, and focus the first invalid field.
- Link errors to inputs with
aria-invalid,aria-describedbyand unique ids fromuseId(). - Disable submit while sending, and show server field errors next to their fields.
- Schemas (Zod, Valibot, Yup) describe all rules in one object and can be shared with the server.
- VeeValidate and FormKit are mature libraries for large form-heavy apps.
Exercise
Extend Rohan's form with an email field that has two rules: required, and a simple format check. Then add a course <select> (JEE, NEET, Foundation) that must be chosen. You will need a small BaseSelect component that works with form.field('course'). Finally, show a count of current errors above the form ("2 fields need attention") that appears only after the first submit attempt.
Show solution
<!-- Add to the components -->
<script>
const BaseSelect = {
inheritAttrs: false,
props: { label: String, modelValue: String, error: String, options: Array },
emits: ["update:modelValue", "blur"],
setup() { return { id: Vue.useId() }; },
template: `
<div class="field">
<label :for="id">{{ label }}</label>
<select :id="id" :value="modelValue" :aria-invalid="error ? 'true' : 'false'"
:aria-describedby="error ? id + '-error' : null"
@change="$emit('update:modelValue', $event.target.value); $emit('blur')">
<option value="">Choose...</option>
<option v-for="o in options" :key="o">{{ o }}</option>
</select>
<small v-if="error" :id="id + '-error'" class="error">{{ error }}</small>
</div>`
};
</script>
<!-- New rules in useForm(...) -->
email: [required("Enter your email"), pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Enter a valid email")],
course: [required("Choose a course")]
<!-- In the template, above the form fields -->
<p v-if="tried && errorCount" role="alert">{{ errorCount }} field(s) need attention</p>
<BaseField label="Email" type="email" v-bind="form.field('email')" autocomplete="email" />
<BaseSelect label="Course" :options="['JEE', 'NEET', 'Foundation']" v-bind="form.field('course')" />
<!-- In setup() -->
const tried = ref(false);
const errorCount = computed(() => Object.values(form.errors).filter(Boolean).length);
// call tried.value = true at the start of a wrapper around onSubmitAdd email: "" and course: "" to the initial values. Because BaseSelect accepts the same props and events as BaseField, form.field() works with it unchanged; that is the payoff of a shared field contract.
Quick Quiz
1. When should a professional form first show an error for a field?
Waiting for blur or submit avoids shouting at users who are still typing.
2. Which attributes connect an error message to its input for screen readers?
Assistive technology reads the description linked by aria-describedby; colour alone carries no meaning.
3. What does Zod's safeParse() return for invalid data?
safeParse() never throws; use parse() if you want an exception.
4. Why must the server validate data even when the Vue form does?
Front-end validation improves the experience; only server validation protects your data.
Learn Vue.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
