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:
- Value layer — the field’s current string/number value, owned by Vue refs.
- Validity layer — pass/fail plus a message, computed by a validator function or schema.
- Presentation layer —
aria-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.
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 meta — touched, 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 anaria-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.
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.
Related Guides
- Typed Schema Validation with VeeValidate and Zod — wire
toTypedSchemato a Zod schema for end-to-end type inference - React Hook Form Validation — the equivalent pattern in the React ecosystem
- Angular Reactive Forms Validation — the equivalent pattern with
FormGroup/FormControl - Schema-Based Validation with Zod — author the schemas VeeValidate consumes
- Constraint Validation API Deep Dive — the native contract VeeValidate mirrors
← Back to Framework Integration Patterns