Vue VeeValidate Validation: Composition API, Schemas, and Accessible Errors

VeeValidate 4 layers a reactive validation engine over Vue 3’s Composition API, mapping field state, error bags, and submission gating onto the same primitives the browser already exposes through native constraint checking. This guide shows how to wire useForm/useField and the <Form>/<Field> components, drive them from a typed schema, run asynchronous rules without race conditions, and render errors that satisfy assistive technology — all while keeping the native Constraint Validation API Deep Dive as the underlying contract.

Why VeeValidate Sits on Top of Native Validation

The browser already evaluates required, type="email", pattern, and min/max during DOM construction and exposes the result through input.validity and input.validationMessage. VeeValidate does not replace that machinery — it mirrors it into Vue’s reactivity graph so templates can react to validity without manual DOM reads. The house pattern across this site is <form novalidate> plus a manual reportValidity()/checkValidity() call; VeeValidate is the framework-native expression of the same idea. You set novalidate (VeeValidate’s <Form> does this for you), suppress the browser’s blocking popups, and take ownership of when and how errors surface, exactly as the UX Patterns & Error State Design guidance prescribes.

The mental model has three layers that must stay in sync:

  1. Value layer — the field’s current string/number value, owned by Vue refs.
  2. Validity layer — pass/fail plus a message, computed by a validator function or schema.
  3. Presentation layeraria-invalid, aria-describedby, and the visible error node.

VeeValidate owns layers 1 and 2; you remain responsible for layer 3, just as you would when calling setCustomValidity() directly.

VeeValidate validation data flow An input event updates a field ref, the schema validates the value, the result populates the reactive error bag and the meta state, and the template renders aria-invalid and a described-by error node. <Field> input handleChange field ref value + meta typed schema validate() valid → submit error bag errors[name] render layer aria-invalid describes field
VeeValidate routes each input through a reactive field ref into the schema, then fans the result out to a submit gate and an error bag that drives accessible rendering.

Where VeeValidate Adds Value Over Raw Native Validation

Native constraint checking is sufficient for static forms with simple, field-local rules: a required email, a pattern-matched username. The moment requirements grow — conditional fields, cross-field dependencies, server-side uniqueness, typed payloads shared with a backend — hand-rolling that logic against the imperative validity/setCustomValidity surface becomes error-prone. VeeValidate earns its place by providing reactive bookkeeping (which field is touched, dirty, pending, valid) and a declarative schema entry point, while deliberately not hiding the native primitives underneath. The trade-off is a small runtime cost and a dependency; the payoff is that validation state becomes ordinary Vue reactive data you can compose, watch, and test.

Concern Raw native validation VeeValidate 4
Field-local rules Constraint attributes Schema or inline rules
Cross-field rules Manual setCustomValidity refine/superRefine in schema
Async/server rules Manual fetch + flag useField async validator
Touched/dirty tracking Manual dataset flags meta per field
Typed payload None z.infer end-to-end
Error rendering You own it You own it (same contract)

Prerequisites

Requirement Minimum Notes
Vue 3.3+ Composition API with <script setup>
vee-validate 4.12+ useForm, useField, <Form>, <Field>
@vee-validate/zod or @vee-validate/yup 4.x Provides toTypedSchema
zod 3.22+ Schema source for typed validation
TypeScript 5.0+ Required for typed field inference

Core API Reference

Symbol Layer Returns / Purpose
useForm({ validationSchema }) Composition handleSubmit, errors, values, meta, defineField, setFieldError, setErrors
useField(name, rules?) Composition value, errorMessage, meta, handleBlur, handleChange
defineField(name) Composition [model, attrs] tuple binding value plus validation triggers
<Form :validation-schema> Component Renderless form, sets novalidate, exposes slot props
<Field name rules> Component Renders a control, tracks value and validity
<ErrorMessage name> Component Renders the message string for one field
toTypedSchema(schema) Adapter Wraps a Zod/Yup schema into a VeeValidate-typed schema
meta.touched / meta.dirty / meta.valid State Controls when to surface errors

Step 1 — Composition API Setup with useForm and defineField

The Composition API gives the most control and is the recommended entry point for typed forms. useForm accepts a typed schema, and defineField produces a v-model-ready ref plus an attrs object carrying the blur/change triggers.

// useRegistrationForm.ts
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

// The schema is the single source of truth — the same role HTML constraint
// attributes play for native validation.
const schema = toTypedSchema(
  z.object({
    email: z.string().min(1, 'Email is required').email('Enter a valid email'),
    username: z
      .string()
      .min(3, 'At least 3 characters')
      .max(20, 'At most 20 characters')
      .regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers and underscores only'),
    age: z.coerce.number().int().min(18, 'Must be 18 or older'),
  }),
);

export function useRegistrationForm() {
  const { handleSubmit, errors, meta, defineField, setFieldError } = useForm({
    validationSchema: schema,
    // Mirror native behavior: validate on blur first, then on every input
    // once the field is dirty, to avoid premature error display.
    validateOnMount: false,
  });

  // Each tuple: [reactive model, attrs with handleBlur/handleChange]
  const [email, emailAttrs] = defineField('email');
  const [username, usernameAttrs] = defineField('username');
  const [age, ageAttrs] = defineField('age');

  return {
    handleSubmit,
    errors,
    meta,
    setFieldError,
    fields: { email, emailAttrs, username, usernameAttrs, age, ageAttrs },
  };
}

The handleSubmit wrapper is the framework analog of the canonical form.checkValidity() gate: it runs the full schema, and your callback only fires when every field passes. If validation fails, VeeValidate populates errors and focuses nothing automatically — focus management remains your responsibility, exactly as covered in Focus Management & Keyboard Navigation.

Step 2 — Binding the Template with Accessible Markup

<!-- RegistrationForm.vue -->
<script setup lang="ts">
import { useRegistrationForm } from './useRegistrationForm';

const { handleSubmit, errors, fields } = useRegistrationForm();

const onSubmit = handleSubmit((values) => {
  // values is fully typed: { email: string; username: string; age: number }
  return submitRegistration(values);
});
</script>

<template>
  <!-- novalidate is implied; we never want native popups here -->
  <form novalidate @submit="onSubmit">
    <div class="form-group">
      <label for="email">Email Address</label>
      <input
        id="email"
        type="email"
        v-model="fields.email"
        v-bind="fields.emailAttrs"
        :aria-invalid="!!errors.email"
        :aria-describedby="errors.email ? 'email-error' : undefined"
        autocomplete="email"
      />
      <p v-if="errors.email" id="email-error" class="error" role="alert">
        {{ errors.email }}
      </p>
    </div>

    <div class="form-group">
      <label for="username">Username</label>
      <input
        id="username"
        type="text"
        v-model="fields.username"
        v-bind="fields.usernameAttrs"
        :aria-invalid="!!errors.username"
        :aria-describedby="errors.username ? 'username-error' : undefined"
        autocomplete="username"
      />
      <p v-if="errors.username" id="username-error" class="error" role="alert">
        {{ errors.username }}
      </p>
    </div>

    <button type="submit">Create Account</button>
  </form>
</template>

The :aria-invalid and conditional :aria-describedby bindings are the contract that satisfies WCAG 2.2 Success Criterion 3.3.1 (Error Identification): the error node is programmatically associated with the field, and role="alert" announces it to screen readers when it appears — the same association pattern native validation establishes through validationMessage.

Step 3 — The <Form>/<Field> Component API

For simpler forms or when you prefer template-driven declarations, the renderless components encapsulate the same engine. <Form> applies novalidate automatically and exposes errors and meta as slot props.

<!-- ComponentApiForm.vue -->
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const schema = toTypedSchema(
  z.object({
    email: z.string().email('Enter a valid email'),
    password: z.string().min(8, 'At least 8 characters'),
  }),
);

function onSubmit(values: { email: string; password: string }) {
  return submitLogin(values);
}
</script>

<template>
  <Form :validation-schema="schema" @submit="onSubmit" v-slot="{ errors, meta }">
    <div class="form-group">
      <label for="login-email">Email</label>
      <Field
        id="login-email"
        name="email"
        type="email"
        :aria-invalid="!!errors.email"
        :aria-describedby="errors.email ? 'login-email-error' : undefined"
      />
      <ErrorMessage name="email" id="login-email-error" as="p" class="error" role="alert" />
    </div>

    <button type="submit" :disabled="meta.pending">Sign In</button>
  </Form>
</template>

meta.pending is true while an asynchronous rule is in flight, letting you disable the submit button — the framework-native version of the loading-state pattern documented in the Form Submission Lifecycle.

Step 4 — Error Bags and Granular Error Access

VeeValidate exposes errors in three shapes; choose the one that matches your rendering need.

Accessor Shape Use case
errors Record<string, string | undefined> First message per field
errorBag Record<string, string[]> All messages per field (multi-rule display)
useField(name).errorMessage Ref<string | undefined> Isolated single-field component

To set a server-derived error after submission — the analog of calling setCustomValidity() with a message returned from the backend — use setFieldError:

const onSubmit = handleSubmit(async (values) => {
  try {
    await submitRegistration(values);
  } catch (err) {
    if (err instanceof ConflictError) {
      // Surface a server-side rule through the same error channel as client rules
      setFieldError('username', 'That username is already taken');
    }
  }
});

Step 5 — Asynchronous Rules Without Race Conditions

Async validators (uniqueness checks, server-side availability) introduce the same stale-response hazard as any asynchronous server check. VeeValidate awaits each validation, but rapid typing can still fire overlapping requests. Debounce the trigger and cancel superseded requests with AbortController.

import { useField } from 'vee-validate';

let controller: AbortController | null = null;

const { value, errorMessage, meta } = useField<string>('username', async (val) => {
  if (typeof val !== 'string' || val.length < 3) {
    return 'At least 3 characters';
  }

  // Cancel any in-flight check before starting a new one
  controller?.abort();
  controller = new AbortController();

  try {
    const res = await fetch(`/api/username-available?u=${encodeURIComponent(val)}`, {
      signal: controller.signal,
    });
    const { available } = (await res.json()) as { available: boolean };
    return available ? true : 'That username is already taken';
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      // Superseded request — treat as still-validating, not an error
      return true;
    }
    return 'Could not verify username, please retry';
  }
});

Pair this with a debounce on the input handler (300–500ms is the standard window) so you do not dispatch a request per keystroke. The meta.pending flag flips to true during the await, which you can bind to a spinner adjacent to the field.

Controlling Validation Timing with meta

VeeValidate tracks per-field interaction state on metatouched, dirty, valid, and pending — which is the lever for the premature-feedback problem the native pattern solves by only reporting errors after the first blur. Showing an error before the user has touched a field is a common accessibility and UX regression; gate rendering on meta.touched so a pristine form paints clean.

import { useField } from 'vee-validate';

const { value, errorMessage, meta } = useField<string>('email');

// Only surface the message once the field has been blurred at least once.
const showError = computed(() => meta.touched && !!errorMessage.value);

You can tune when validation runs globally through the form’s configuration. By default VeeValidate validates on change and blur; for fields with expensive rules, validate lazily on blur only, mirroring the updateOn lever Angular exposes. The form-wide meta.valid and meta.dirty flags let you disable the submit button until the user has made a meaningful edit, avoiding a no-op submission that would only surface errors.

const { meta: formMeta, handleSubmit } = useForm({ validationSchema: schema });

// Disable submit until the form is both dirty and valid — no premature errors.
const canSubmit = computed(() => formMeta.value.dirty && formMeta.value.valid);

Rendering a Global Error Summary

For longer forms, a single error summary at the top of the form — focused on submit — gives keyboard and screen-reader users one place to understand every failure, complementing the inline messages. This is the UX Patterns & Error State Design recommendation for multi-field forms, and it pairs naturally with VeeValidate’s keyed errors bag.

<!-- ErrorSummary.vue -->
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{ errors: Record<string, string | undefined> }>();

const items = computed(() =>
  Object.entries(props.errors)
    .filter(([, msg]) => !!msg)
    .map(([field, msg]) => ({ field, msg: msg as string })),
);
</script>

<template>
  <div
    v-if="items.length"
    role="alert"
    tabindex="-1"
    class="error-summary"
    ref="summaryEl"
  >
    <h2>There {{ items.length === 1 ? 'is' : 'are' }} {{ items.length }} problem(s)</h2>
    <ul>
      <li v-for="item in items" :key="item.field">
        <!-- Anchor links move focus straight to the offending field -->
        <a :href="`#${item.field}`">{{ item.msg }}</a>
      </li>
    </ul>
  </div>
</template>

On a failed submit, move focus to the summary container (summaryEl.value?.focus()) so assistive technology announces the count and each link routes to its field — the framework-native equivalent of focusing the first :invalid element after a native reportValidity() call.

Accessibility Compliance

  • SC 3.3.1 Error Identification — bind :aria-invalid="!!errors.field" and associate the message via :aria-describedby.
  • SC 3.3.3 Error Suggestion — write schema messages that state the fix (“At least 8 characters”), not just the failure.
  • SC 4.1.3 Status Messages — give async/global errors role="alert" or an aria-live="polite" region so they announce without stealing focus.
  • SC 2.4.3 Focus Order — on a failed submit, move focus to the first invalid field; VeeValidate does not do this for you.
const onSubmit = handleSubmit(
  (values) => submitRegistration(values),
  ({ errors }) => {
    // invalid submission callback — focus the first field with an error
    const firstKey = Object.keys(errors)[0];
    if (firstKey) document.getElementById(firstKey)?.focus();
  },
);

Common Gotchas

1. Using v-model without defineField attrs. Binding only the model ref skips the blur/change triggers, so fields validate at the wrong time.

// Before — validation never fires on blur
<input v-model="email" />

// After — attrs carry handleBlur/handleChange
<input v-model="email" v-bind="emailAttrs" />

2. Reading errors.field before the field is touched. This shows errors on first paint. Gate on meta.touched or rely on validateOnBlur defaults rather than validateOnMount: true.

3. Forgetting z.coerce for numeric inputs. Native inputs always yield strings; a z.number() rule fails immediately. Use z.coerce.number() so the string is parsed first.

// Before — always fails because value is "25" (string)
age: z.number().min(18)

// After — coerces "25" → 25 before the min check
age: z.coerce.number().min(18)

4. Mutating errors directly. It is read-only reactive state. Use setFieldError/setErrors to write.

5. Not aborting stale async requests. Without AbortController, a slow early response can overwrite a fast later one, marking a valid field invalid.

Browser Compatibility

Capability Chrome Firefox Safari Notes
Vue 3 reactivity (Proxy) 64+ 60+ 12+ No IE support
AbortController 66+ 57+ 12.1+ For async cancellation
:user-invalid CSS hook 119+ 88+ 16.5+ Optional native styling layer
aria-live / role="alert" All All All Core a11y contract

Because VeeValidate computes validity in JavaScript, the validation logic itself is browser-agnostic; only the optional native CSS hooks and async cancellation depend on engine support.

Cross-Field Rules with superRefine

Field-local constraints — required, length, pattern — map cleanly onto the schema’s per-key rules. Rules that compare two or more fields do not, because a single key’s validator cannot see its siblings. The native surface forces you to call setCustomValidity() on one control from a listener on another and manually clear it when the dependency changes. Zod’s refine/superRefine expresses the same relationship declaratively, and because VeeValidate runs the whole object schema on every validation pass, the cross-field message stays in sync automatically.

The canonical case is password confirmation. Attach the error to the confirmation field so the message lands where the user can act on it, and use superRefine when you need to add issues to a specific path:

import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const changePasswordSchema = toTypedSchema(
  z
    .object({
      password: z.string().min(8, 'At least 8 characters'),
      confirm: z.string().min(1, 'Please confirm your password'),
      // A conditional dependency: a reason is only required when downgrading.
      newTier: z.enum(['free', 'pro']),
      downgradeReason: z.string().optional(),
    })
    .superRefine((data, ctx) => {
      if (data.password !== data.confirm) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ['confirm'], // message surfaces on the confirm field, not the object root
          message: 'Passwords do not match',
        });
      }
      if (data.newTier === 'free' && !data.downgradeReason?.trim()) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ['downgradeReason'],
          message: 'Tell us why you are downgrading',
        });
      }
    }),
);

The subtle part is the path array: without it Zod attaches the issue to the object root, where errors.confirm will never see it and no field lights up. Because the refinement re-runs on each keystroke of either field, editing password after confirm was already flagged clears the mismatch message the moment the two values agree — behaviour that is tedious to replicate by hand against the imperative validity API. See Schema-Based Validation with Zod for the full refinement vocabulary.

Dynamic Field Arrays with useFieldArray

Forms that add and remove rows — invoice line items, multiple email addresses, tag lists — need per-row validity that survives insertion and deletion without re-indexing errors onto the wrong row. useFieldArray tracks a stable key per entry so Vue’s v-for reconciliation and the error bag stay aligned even as the array reorders.

import { useForm, useFieldArray } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';

const schema = toTypedSchema(
  z.object({
    recipients: z
      .array(z.object({ email: z.string().email('Enter a valid email') }))
      .min(1, 'Add at least one recipient'),
  }),
);

export function useRecipientsForm() {
  useForm({ validationSchema: schema, initialValues: { recipients: [{ email: '' }] } });

  // fields is a reactive array; each entry carries a stable `key` for :key binding
  const { fields, push, remove } = useFieldArray<{ email: string }>('recipients');

  return { fields, push, remove };
}

In the template, bind :key="field.key" — never the loop index — so removing row 1 does not shift row 2’s error onto row 1’s freshly rendered input. Address each nested field by its dotted path (recipients[0].email) when reading errors, matching the path Zod reports for array members. The array-level min(1) rule surfaces under the recipients key itself, so render that message above the list as a group-level constraint rather than on any single row.

Frequently Asked Questions

Does VeeValidate replace the native Constraint Validation API?

No. It mirrors the same pass/fail/message model into Vue's reactivity so templates can react without manual DOM reads. The form still renders novalidate and you still own when errors surface — VeeValidate is the framework-native expression of the manual checkValidity() pattern.

Should I use useForm or the <Form> component?

Use useForm with defineField for typed forms where you need programmatic access to errors, meta, and setFieldError. Use <Form>/<Field> for simpler, template-driven forms. Both share the same engine and schema format.

How do I prevent async validators from racing?

Debounce the input by 300–500ms and abort any in-flight request with AbortController before starting a new one. Treat an AbortError as still-validating rather than a failure so a cancelled request never marks a valid field invalid.

Why does my numeric field always fail validation?

Native inputs always emit strings, so a z.number() rule rejects "25". Wrap the field in z.coerce.number() so the string is parsed to a number before the min/max checks run.

How do I validate one field against another, like password confirmation?

Express the relationship at the object level with Zod's superRefine and pass a path array so the issue attaches to the field you want to light up (usually the confirmation field). Because VeeValidate re-runs the whole schema on each edit, the mismatch message clears automatically as soon as the two values agree — no manual setCustomValidity() bookkeeping.

Why do errors land on the wrong row after I delete an item?

You are binding :key to the loop index instead of the stable key. Use useFieldArray and bind :key="field.key" so Vue reconciles each row to its own value and error. Indexes shift on removal, which reassigns the wrong error to a re-rendered input.

← Back to Framework Integration Patterns

Explore This Section