Validation at Scale
VeeValidate's useForm and defineField with a typed schema: field-level errors, form-level rules, and where the schema lives.
const [field, fieldAttrs] = defineField('email', { validateOnModelUpdate: false })Hand-written validation stops paying off at about three fields. Tracking touched state, first messages, submit state and reset for each one is repetitive code with nothing interesting in it. VeeValidate keeps that bookkeeping and leaves you the rules.
useForm and defineField
useForm takes a validationSchema and returns the form's state: values, errors (the first message per field), errorBag (every message per field), meta (valid, dirty, touched, pending), isSubmitting, handleSubmit, resetForm, setFieldError and setErrors. defineField('email') returns a pair: a model to put on v-model, and an object of attributes and listeners to spread with v-bind. Binding that second item is what connects the input's events to the form.
Schema resolvers
toTypedSchema adapts a schema library to VeeValidate and carries its types through, so the values argument your submit handler receives is typed from the schema. It ships as a separate package per library: @vee-validate/zod or @vee-validate/valibot. The handler returned by handleSubmit prevents the form's default submission itself, so @submit="onSubmit" is enough.
Field-level and form-level
A schema passed to useForm is form-level: one object describing every field, validated together. Field-level validation is the other direction, with useField inside an input component that registers itself with whichever form encloses it. Keep the schema in its own module next to the form, not inside the component, so tests and the code that sends the values can import the same rules.
Example
<!-- src/components/LoginForm.vue -->
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const { defineField, errors, meta, handleSubmit, isSubmitting, setErrors } = useForm({
validationSchema: toTypedSchema(
z.object({
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters')
})
)
})
const [email, emailAttrs] = defineField('email', { validateOnModelUpdate: false })
const [password, passwordAttrs] = defineField('password', { validateOnModelUpdate: false })
const onSubmit = handleSubmit((values) => {
if (values.email.endsWith('@example.com')) {
setErrors({ email: 'That address is not allowed' })
}
})
</script>
<template>
<form @submit="onSubmit">
<label for="email">Email</label>
<input id="email" v-model="email" v-bind="emailAttrs" type="email" />
<p v-if="errors.email">{{ errors.email }}</p>
<label for="password">Password</label>
<input id="password" v-model="password" v-bind="passwordAttrs" type="password" />
<p v-if="errors.password">{{ errors.password }}</p>
<button type="submit" :disabled="isSubmitting || !meta.valid">Sign in</button>
</form>
</template>
<style scoped>
form p {
margin: 0.25rem 0 0.75rem;
color: #b00020;
}
</style>When to use it
- Replace per-field touched and error bookkeeping in a form with ten or more inputs.
- Reuse one schema for the form, the request payload type and the tests.
- Attach a server's rejection to the field it belongs to with setErrors.
- Disable the submit button until the whole form validates, using meta.valid.
More examples
The schema as a module, with its type
Exporting the raw schema, the inferred type and the adapted schema from one file gives each consumer what it needs: the component imports `loginSchema` for `useForm`, the function that posts the values imports `LoginValues`, and a test imports `loginShape` and calls `safeParse` directly with no component mounted.
// src/forms/loginSchema.ts
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
export const loginShape = z.object({
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters')
})
export type LoginValues = z.infer<typeof loginShape>
export const loginSchema = toTypedSchema(loginShape)
console.log(loginShape.safeParse({ email: 'nope', password: 'secret12' }).success)
// Output: falseA field that registers itself
`useField` finds the enclosing form and registers under the name you give it, so the parent only passes a string. The form's schema still supplies the rule. Use this shape once the same field markup appears in several forms; the component knows nothing about which rules apply to it.
<!-- src/components/ValidatedInput.vue -->
<script setup lang="ts">
import { useField } from 'vee-validate'
import { useId } from 'vue'
const props = defineProps<{
name: string
label: string
type?: string
}>()
const { value, errorMessage, handleBlur } = useField<string>(() => props.name)
const fieldId = useId()
</script>
<template>
<div class="field">
<label :for="fieldId">{{ label }}</label>
<input
:id="fieldId"
v-model="value"
:type="props.type ?? 'text'"
:aria-invalid="errorMessage ? 'true' : undefined"
@blur="handleBlur"
/>
<p v-if="errorMessage">{{ errorMessage }}</p>
</div>
</template>
<style scoped>
.field p {
margin: 0.25rem 0 0;
color: #b00020;
}
</style>The declarative component form
The `Form`, `Field` and `ErrorMessage` components are built on the same composables and are what most existing VeeValidate code uses. There is less wiring, but less control over markup and timing. Worth being able to read, because you will meet it in other people's forms.
<!-- src/components/LoginFormDeclarative.vue -->
<script setup lang="ts">
import { Form, Field, ErrorMessage } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const schema = toTypedSchema(
z.object({
email: z.email('Enter a valid email address'),
password: z.string().min(8, 'Use at least 8 characters')
})
)
function onSubmit(values: Record<string, unknown>) {
console.log(values.email)
}
</script>
<template>
<Form :validation-schema="schema" @submit="onSubmit">
<label for="email">Email</label>
<Field id="email" name="email" type="email" />
<ErrorMessage name="email" />
<label for="password">Password</label>
<Field id="password" name="password" type="password" />
<ErrorMessage name="password" />
<button type="submit">Sign in</button>
</Form>
</template>
<style scoped>
label {
display: block;
}
</style>
Discussion