EthicsComputerCourses
Join training +91 90532 10052

Forms at Scale & Validation

Vue.js · Lesson 29 of 41 Intermediate 7 min read Updated

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-invalid and aria-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:

  1. 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") and pattern(/^[6-9]\d{9}$/, "...") are rule factories that create such functions.
  2. Create useForm(initialValues, rules). It keeps values, errors and touched in reactive objects, plus a submitting ref.
  3. Add validateField(name). It runs that field's rules in order and stores the first message.
  4. 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 with v-bind.
  5. 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.
  6. Build BaseField. It renders the label, the input and the error, generates a unique id with useId(), and links them with ARIA attributes.
  7. Map server errors. When the API rejects the data, copy its field errors into errors with setErrors().
HTMLTry it yourself
<!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):

JavaScript
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);
Output
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:

HTML
<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>
ToolWhat it isChoose it when
Your own useForm()About 60 lines you fully controlA few forms with simple rules
VeeValidateComposition API form state plus schema supportMany forms, complex rules, field arrays
FormKitA form framework that also renders inputs, labels and errorsYou want ready-made, styled inputs quickly
Zod, Valibot, YupSchema libraries, not tied to VueSharing 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-invalid and link the message with aria-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 submitting is 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-describedby and unique ids from useId().
  • 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
HTML
<!-- 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 onSubmit

Add 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?

2. Which attributes connect an error message to its input for screen readers?

3. What does Zod's safeParse() return for invalid data?

4. Why must the server validate data even when the Vue form does?

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