useFormValidator
Vue composables for form validation with any Standard Schema library (Valibot, Zod, ArkType...) - useFormValidator and useFormField provide a flexible and typed approach to handle form validation in your Vue applications.
Introduction
useFormValidator and useFormField are two Vue composables that work together to provide powerful form validation. They are validation library agnostic: any library implementing Standard Schema works out of the box, like Valibot, Zod or ArkType. See Validation Libraries.
- useFormValidator: Initializes form validation for your entire form. Use it in your form's parent component.
- useFormField: Manages individual field validation states. Use it when you need fine-grained control over a field or when fields are in child components.
When to use each:
| Composable | Use When |
|---|---|
useFormValidator only | Simple forms where all fields are in the same component |
useFormValidator + useFormField | Fields in child components, or when using eager, blur, or progressive validation modes |
Quick Start
Install the validation library of your choice (Valibot is used in most examples of this page):
pnpm add valibotpnpm add zodpnpm add arktypeHere's the simplest form you can create with useFormValidator:
Validation Libraries
useFormValidator relies on Standard Schema, a common interface shared by the main validation libraries. Maz-UI does not ship any validation library: install the one you prefer and pass its schemas. Valibot, Zod and ArkType are declared as optional peer dependencies of maz-ui, so your package manager warns you if the installed version is not supported.
| Library | Supported versions |
|---|---|
| Valibot | >=1.0.0 <2.0.0 |
| Zod | >=3.24.0 <5.0.0 (v3 and v4) |
| ArkType | >=2.0.0 <3.0.0 |
| Others | Any library implementing Standard Schema V1 |
The schema option is always a plain object where each key is a field name and each value is a schema of your library. Do not wrap it with object() / z.object().
Same form, different libraries
import { useFormValidator } from 'maz-ui/composables'
import { email, minLength, nonEmpty, pipe, string } from 'valibot'
const { model, errorMessages, handleSubmit } = useFormValidator({
schema: {
email: pipe(string(), nonEmpty('Email is required'), email('Invalid email')),
password: pipe(string(), minLength(8, 'Min 8 characters')),
},
})import { useFormValidator } from 'maz-ui/composables'
import { z } from 'zod'
const { model, errorMessages, handleSubmit } = useFormValidator({
schema: {
email: z.string().min(1, 'Email is required').pipe(z.email('Invalid email')),
password: z.string().min(8, 'Min 8 characters'),
},
})import { type } from 'arktype'
import { useFormValidator } from 'maz-ui/composables'
const { model, errorMessages, handleSubmit } = useFormValidator({
schema: {
email: type('string.email'),
password: type('string >= 8'),
},
})Live example with Zod
Mixing libraries
Each field is validated by its own schema, so you can mix libraries in the same form (useful during a progressive migration from one library to another):
import { useFormValidator } from 'maz-ui/composables'
import { email, pipe, string } from 'valibot'
import { z } from 'zod'
const { model } = useFormValidator({
schema: {
email: pipe(string(), email('Invalid email')),
age: z.number().min(18, 'Min 18'),
},
})Custom schema
You can also write your own validator by implementing the StandardSchemaV1 interface (exported by maz-ui/composables). validate can be synchronous or return a Promise:
import type { StandardSchemaV1 } from 'maz-ui/composables'
import { useFormValidator } from 'maz-ui/composables'
const frenchZipCode: StandardSchemaV1<string, string> = {
'~standard': {
version: 1,
vendor: 'my-app',
validate: value =>
typeof value === 'string' && /^\d{5}$/.test(value)
? { value }
: { issues: [{ message: 'Invalid zip code' }] },
},
}
const { model } = useFormValidator({
schema: { zipCode: frenchZipCode },
})Good to know
- Empty values:
undefinedandnullfield values are validated as an empty string (''). To make a field required, use a length rule (nonEmpty()with Valibot,.min(1)with Zod). An optional field with a format rule (email, URL...) has to accept the empty string explicitly, for examplez.email().or(z.literal(''))with Zod orunion([literal(''), pipe(string(), email())])with Valibot. - Error messages:
errorMessagescontains the message of the first issue of each field, whatever the library. - Issues type:
errorsandfieldsStates[field].errorsare typed asValidationIssues, the Standard Schema issue shape (messageandpath). At runtime, they are the native issues of your library, so you can cast them to access library specific properties (for exampleBaseIssue<unknown>from Valibot orz.core.$ZodIssuefrom Zod). - Type inference: the
modeltype (input) and thehandleSubmitpayload type (output, after transformations) are inferred from the schemas whatever the library.
Understanding Form State
useFormValidator returns several reactive values to help you manage your form:
| Property | Type | Description |
|---|---|---|
model | Ref<Model> | The form data object - bind this to your inputs with v-model |
isValid | ComputedRef<boolean> | true when all fields pass validation |
isDirty | ComputedRef<boolean> | true when any field has been modified from its initial value |
isSubmitting | Ref<boolean> | true while the form is being submitted |
isSubmitted | Ref<boolean> | true after the form has been submitted at least once |
errorMessages | ComputedRef<Record<string, string>> | The first error message for each field (if any) |
errors | ComputedRef<Record<string, ValidationIssues>> | All validation issues for each field |
fieldsStates | Ref<FieldsStates> | Detailed state object for each field |
handleSubmit | Function | Wrapper function for form submission |
validateForm | Function | Manually trigger form validation |
resetForm | Function | Reset the form to its initial state |
scrollToError | Function | Scroll to the first field with an error |
Field States
Each field in fieldsStates contains:
| Property | Type | Description |
|---|---|---|
valid | boolean | Field passes validation |
error | boolean | Field has an error that should be displayed |
errors | ValidationIssues | Array of all validation issues |
dirty | boolean | Field value differs from initial value |
blurred | boolean | Field has lost focus at least once |
validated | boolean | Validation has run at least once |
validating | boolean | Async validation is in progress |
Form State:
{
"isValid": false,
"isDirty": false,
"isSubmitted": false,
"isSubmitting": false
}Fields States:
{
"name": {
"blurred": false,
"dirty": false,
"errors": [],
"error": false,
"valid": false,
"validating": false,
"validated": false,
"mode": "lazy"
},
"age": {
"blurred": false,
"dirty": false,
"errors": [],
"error": false,
"valid": false,
"validating": false,
"validated": false,
"mode": "lazy"
}
}Validation Modes
Validation modes control when validation runs and when errors are displayed. Choose the mode that best fits your UX needs.
| Mode | Validates On | Shows Errors | Best For |
|---|---|---|---|
lazy (default) | Value change | After change (if not empty) | Simple forms |
aggressive | Immediately + every change | Always | Real-time feedback |
eager | Blur, then on change | After first blur | Better UX |
blur | Only on blur | After blur | Minimal interruption |
progressive | Silently, shows on blur if invalid | After blur or validation | Optimal UX |
TIP
For eager, blur, and progressive modes, you must use useFormField with the ref option or validationEvents to capture blur events.
Lazy Mode (Default)
The default mode. Validates when field values change. Errors only appear if the field is not empty.
Type in the field and clear it - notice the error appears only when there's content.
Aggressive Mode
Validates all fields immediately when the form is created and on every change. Errors are always displayed.
Notice all fields show errors immediately, even before any interaction.
Eager Mode (Recommended)
Validates on blur first (if the field is not empty), then on every change. This provides a good balance between immediate feedback and not overwhelming the user.
WARNING
Requires useFormField with ref option or validationEvents.
Type something, then click outside the field (blur) to see validation. After that, errors update as you type.
Blur Mode
WARNING
Requires useFormField with ref option or validationEvents.
Validates only when the field loses focus. Errors are only shown after blur.
Type in the field, then click outside. Errors only appear after blur, and don't update while typing.
Progressive Mode
WARNING
Requires useFormField with ref option or validationEvents.
The most user-friendly mode. Validates silently in the background. Shows errors only on blur if the field is invalid. Once valid, it stays valid until it becomes invalid again.
Start typing - the field becomes valid (green) as soon as validation passes. Errors only show after blur.
useFormField for Child Components
useFormField is essential when:
- Your form fields are in child components
- You need the
eager,blur, orprogressivevalidation modes - You want fine-grained control over individual field states
Return Values
| Property | Type | Description |
|---|---|---|
value | WritableComputedRef<T> | The field value (use with v-model) |
hasError | ComputedRef<boolean> | Field has an error that should be displayed |
errors | ComputedRef<ValidationIssues> | All validation issues |
errorMessage | ComputedRef<string> | First error message |
isValid | ComputedRef<boolean> | Field passes validation |
isDirty | ComputedRef<boolean> | Field has been modified |
isBlurred | ComputedRef<boolean> | Field has lost focus |
isValidated | ComputedRef<boolean> | Validation has run |
isValidating | ComputedRef<boolean> | Async validation in progress |
mode | ComputedRef<string> | The validation mode |
validationEvents | ComputedRef<object> | Blur event handler for v-bind |
Two Ways to Bind Validation Events
Option 1: Using ref (Recommended)
Pass a template ref to useFormField. It will automatically detect interactive elements and attach blur listeners.
The ref option is reactive: if the field is rendered conditionally (e.g. with v-if), the blur listeners are attached automatically as soon as the element appears in the DOM, and removed when it is unmounted. You don't need to handle anything special.
<script setup>
import { useFormField } from 'maz-ui/composables'
import { useTemplateRef } from 'vue'
const { value, errorMessage, hasError } = useFormField<string>('email', {
ref: useTemplateRef('emailRef'),
formIdentifier: 'my-form',
})
</script>
<template>
<MazInput
ref="emailRef"
v-model="value"
:hint="errorMessage"
:error="hasError"
/>
</template>Because it is reactive, the same code works even when the input is wrapped in a v-if:
<script setup>
import { useFormField } from 'maz-ui/composables'
import { useTemplateRef, ref } from 'vue'
const isVisible = ref(false)
const { value, errorMessage, hasError } = useFormField<string>('email', {
ref: useTemplateRef('emailRef'),
formIdentifier: 'my-form',
})
</script>
<template>
<MazInput
v-if="isVisible"
ref="emailRef"
v-model="value"
:hint="errorMessage"
:error="hasError"
/>
</template>You can also pass a raw HTMLElement directly. In that case it won't be reactive, so the element must already exist in the DOM:
const { value } = useFormField<string>('email', {
ref: document.querySelector('input'),
formIdentifier: 'my-form',
})Option 2: Using validationEvents
If your component emits a blur event, you can use v-bind with validationEvents.
<script setup>
import { useFormField } from 'maz-ui/composables'
const { value, errorMessage, hasError, validationEvents } = useFormField<string>('email', {
formIdentifier: 'my-form',
})
</script>
<template>
<MazInput
v-model="value"
v-bind="validationEvents"
:hint="errorMessage"
:error="hasError"
/>
</template>TypeScript Type Inference
The form model is automatically typed based on your schema, whatever the validation library:
const schema = {
name: pipe(string(), nonEmpty()),
age: pipe(number(), minValue(0)),
email: pipe(string(), email()),
}
const { model } = useFormValidator({ schema })
// model.value is typed as: { name?: string, age?: number, email?: string }For useFormField, specify the field type as a generic parameter:
// Specify the type for better type safety
const { value } = useFormField<string>('name', { formIdentifier: 'my-form' })
// value is typed as WritableComputedRef<string>Common TypeScript Errors
If you get circular reference errors with useTemplateRef, use the classic ref() instead:
// May cause TypeScript errors
const { value: email } = useFormField<string>('email', {
ref: useTemplateRef('emailRef'),
})
// Solution 1: Add generic to useTemplateRef
const { value: email } = useFormField<string>('email', {
ref: useTemplateRef<HTMLInputElement>('emailRef'),
})
// Solution 2: Use classic ref
const emailRef = ref<HTMLInputElement>()
const { value: email } = useFormField<string>('email', {
ref: emailRef,
})Async Validation
Async validations (like checking username availability) are supported by every library: Valibot's pipeAsync and checkAsync, Zod's async refine, etc. The example below uses Valibot, here is the Zod equivalent of the schema:
import { z } from 'zod'
const schema = {
username: z
.string()
.min(1, 'Username is required')
.min(3, 'Min 3 characters')
.refine(async (value) => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000))
return value !== 'taken'
}, 'Username is already taken'),
}Try typing "taken" - the async validator will reject it after a 2-second delay.
Throttling and Debouncing
For expensive validations (like API calls), use throttling or debouncing to limit how often validation runs.
| Option | Behavior | Default Time | Use Case |
|---|---|---|---|
debouncedFields | Waits until user stops typing | 300ms | Search fields, API calls |
throttledFields | Runs at most once per interval | 1000ms | Rate-limited APIs |
Name has 500ms debounce, Age has 1000ms throttle. Watch the console to see validation timing.
Reset Form
Use resetForm() to reset the form to its initial state, or set resetOnSuccess to automatically reset after successful submission.
Multiple Forms on Same Page
Use the identifier option to have multiple independent forms on the same page. Make sure to match the identifier in both useFormValidator and useFormField.
<script lang="ts" setup>
import { useFormValidator, useFormField } from 'maz-ui/composables'
// Form 1
const form1 = useFormValidator({
schema: schema1,
options: { identifier: 'login-form' },
})
// Form 2
const form2 = useFormValidator({
schema: schema2,
options: { identifier: 'register-form' },
})
// useFormField must use matching identifier
const { value: loginEmail } = useFormField<string>('email', {
formIdentifier: 'login-form',
})
const { value: registerEmail } = useFormField<string>('email', {
formIdentifier: 'register-form',
})
</script>Error Handling and scrollToError
scrollToError
Automatically scroll to the first field with an error when validation fails:
const { handleSubmit } = useFormValidator({
schema,
options: {
scrollToError: '.has-error', // CSS selector for error elements
// scrollToError: false, // Disable scrolling
},
})Add the matching class to your fields:
<MazInput
:class="{ 'has-error': hasError }"
:error="hasError"
/>onError Callback
Handle validation failures with the onError callback:
const onSubmit = handleSubmit(
(data) => {
// Success callback
console.log('Valid:', data)
},
'.has-error', // scrollToError selector (optional)
{
onError: ({ model, errorMessages, errors }) => {
// Called when validation fails
console.log('Validation failed:', errorMessages)
},
}
)Performance & Best Practices
Performance Tips
- Use throttling/debouncing for expensive validations (API calls, complex logic)
- Prefer
eagerorprogressivemodes overaggressivefor better performance - Use
lazymode for simple forms with minimal validation - Avoid
aggressivemode on large forms - it validates every field on every change
Common Patterns
Multiple Forms with Identifiers
const { handleSubmit } = useFormValidator({
schema,
options: { identifier: 'my-unique-form' },
})
const { value } = useFormField<string>('email', {
formIdentifier: 'my-unique-form', // Must match!
})Custom Interactive Elements
If your custom component isn't detected for blur events, add data-interactive:
<div data-interactive class="custom-input" tabindex="0">
Custom Input
</div>Common Pitfalls
| Pitfall | Solution |
|---|---|
Mismatched formIdentifier | Ensure useFormField's formIdentifier matches useFormValidator's identifier |
| Validation not triggering in eager/blur/progressive modes | Use ref option or v-bind="validationEvents" |
TypeScript errors with useTemplateRef | Add generic type or use classic ref() |
| Field not found warning | Make sure the field name exists in your schema |
API Reference
useFormValidator
Parameters
useFormValidator<TSchema>({
schema: TSchema, // Object of Standard Schema field schemas: Valibot, Zod, ArkType... (required)
model?: Ref<Model>, // External model ref (optional)
defaultValues?: DeepPartial<Model>, // Initial values (optional)
options?: {
mode?: 'lazy' | 'aggressive' | 'eager' | 'blur' | 'progressive', // Default: 'lazy'
throttledFields?: Record<string, number | true>, // Fields to throttle
debouncedFields?: Record<string, number | true>, // Fields to debounce
scrollToError?: string | false, // CSS selector, default: '.has-field-error'
identifier?: string | symbol, // Form identifier, default: 'main-form-validator'
resetOnSuccess?: boolean, // Reset after submit, default: true
}
})Return Values
{
identifier: string | symbol
model: Ref<Model>
isValid: ComputedRef<boolean>
isDirty: ComputedRef<boolean>
isSubmitting: Ref<boolean>
isSubmitted: Ref<boolean>
errors: ComputedRef<Record<string, ValidationIssues>>
errorMessages: ComputedRef<Record<string, string | undefined>>
fieldsStates: Ref<FieldsStates<Model>>
validateForm: (setErrors?: boolean) => Promise<void[]>
scrollToError: (selector?: string) => void
resetForm: () => void
handleSubmit: <Func>(
successCallback: Func,
scrollToError?: string | false,
options?: { onError?: Function, resetOnSuccess?: boolean }
) => (event?: Event) => Promise<ReturnType<Func>>
}useFormField
Parameters
useFormField<FieldType>(
name: string, // Field name in schema (required)
options?: {
defaultValue?: FieldType, // Default value for this field
mode?: 'lazy' | 'aggressive' | 'eager' | 'blur' | 'progressive', // Override form mode
ref?: Ref<HTMLElement | ComponentInstance> | HTMLElement, // Reactive template ref (v-if friendly) or raw element
formIdentifier?: string | symbol, // Must match useFormValidator's identifier
}
)Return Values
{
value: WritableComputedRef<FieldType>
hasError: ComputedRef<boolean>
errors: ComputedRef<ValidationIssues>
errorMessage: ComputedRef<string | undefined>
isValid: ComputedRef<boolean>
isDirty: ComputedRef<boolean>
isBlurred: ComputedRef<boolean>
isValidated: ComputedRef<boolean>
isValidating: ComputedRef<boolean>
mode: ComputedRef<string | undefined>
validationEvents: ComputedRef<{ onBlur?: () => void }>
}Types
// Standard Schema issue (https://standardschema.dev)
interface ValidationIssue {
readonly message: string
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
}
type ValidationIssues = ValidationIssue[]
interface FormValidatorOptions<Model> {
mode?: 'eager' | 'lazy' | 'aggressive' | 'blur' | 'progressive'
throttledFields?: Partial<Record<keyof Model, number | true>>
debouncedFields?: Partial<Record<keyof Model, number | true>>
scrollToError?: string | false
identifier?: string | symbol
resetOnSuccess?: boolean
}
interface FormFieldOptions<FieldType> {
defaultValue?: FieldType
mode?: 'eager' | 'lazy' | 'aggressive' | 'blur' | 'progressive'
ref?: Ref<HTMLElement | ComponentInstance> | HTMLElement
formIdentifier?: string | symbol
}
interface FieldState<FieldType> {
valid: boolean
error: boolean
errors: ValidationIssues
dirty: boolean
blurred: boolean
validated: boolean
validating: boolean
initialValue?: Readonly<FieldType>
mode?: string
}Troubleshooting
Type Errors with useTemplateRef
Problem: TypeScript circular reference errors when using useTemplateRef
Solutions:
// Solution 1: Add generic type
const { value } = useFormField<string>('email', {
ref: useTemplateRef<HTMLInputElement>('emailRef'),
})
// Solution 2: Use classic ref
const emailRef = ref<HTMLInputElement>()
const { value } = useFormField<string>('email', { ref: emailRef })Validation Not Triggering
Problem: eager, blur, or progressive mode not validating
Solution: These modes require blur event detection. Use either:
// Option 1: ref option
const { value } = useFormField<string>('name', {
ref: useTemplateRef('inputRef'),
})
// Option 2: validationEvents
const { value, validationEvents } = useFormField<string>('name')
// Then: v-bind="validationEvents" on your inputConditionally Rendered Fields (v-if)
The ref option is reactive, so a field wrapped in v-if works out of the box: blur listeners are attached as soon as the element is rendered and removed when it is unmounted.
If blur validation still doesn't trigger:
- Ensure the
refis bound to an HTML element or a Vue component exposing$el - For custom components, add the
data-interactiveattribute
Mismatched Form Identifiers
Problem: useFormField not finding the form context
Solution: Ensure identifiers match:
// In parent
const { handleSubmit } = useFormValidator({
schema,
options: { identifier: 'my-form' }, // This identifier...
})
// In child
const { value } = useFormField<string>('email', {
formIdentifier: 'my-form', // ...must match this one
})