Skip to content

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:

ComposableUse When
useFormValidator onlySimple forms where all fields are in the same component
useFormValidator + useFormFieldFields 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):

bash
pnpm add valibot
bash
pnpm add zod
bash
pnpm add arktype

Here's the simplest form you can create with useFormValidator:

Email
Password

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.

LibrarySupported 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
OthersAny 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 ​

ts
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')),
  },
})
ts
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'),
  },
})
ts
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 ​

Username
Age

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):

ts
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:

ts
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: undefined and null field 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 example z.email().or(z.literal('')) with Zod or union([literal(''), pipe(string(), email())]) with Valibot.
  • Error messages: errorMessages contains the message of the first issue of each field, whatever the library.
  • Issues type: errors and fieldsStates[field].errors are typed as ValidationIssues, the Standard Schema issue shape (message and path). At runtime, they are the native issues of your library, so you can cast them to access library specific properties (for example BaseIssue<unknown> from Valibot or z.core.$ZodIssue from Zod).
  • Type inference: the model type (input) and the handleSubmit payload 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:

PropertyTypeDescription
modelRef<Model>The form data object - bind this to your inputs with v-model
isValidComputedRef<boolean>true when all fields pass validation
isDirtyComputedRef<boolean>true when any field has been modified from its initial value
isSubmittingRef<boolean>true while the form is being submitted
isSubmittedRef<boolean>true after the form has been submitted at least once
errorMessagesComputedRef<Record<string, string>>The first error message for each field (if any)
errorsComputedRef<Record<string, ValidationIssues>>All validation issues for each field
fieldsStatesRef<FieldsStates>Detailed state object for each field
handleSubmitFunctionWrapper function for form submission
validateFormFunctionManually trigger form validation
resetFormFunctionReset the form to its initial state
scrollToErrorFunctionScroll to the first field with an error

Field States ​

Each field in fieldsStates contains:

PropertyTypeDescription
validbooleanField passes validation
errorbooleanField has an error that should be displayed
errorsValidationIssuesArray of all validation issues
dirtybooleanField value differs from initial value
blurredbooleanField has lost focus at least once
validatedbooleanValidation has run at least once
validatingbooleanAsync validation is in progress
Name (min 3 characters)
Age (18-100)

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.

ModeValidates OnShows ErrorsBest For
lazy (default)Value changeAfter change (if not empty)Simple forms
aggressiveImmediately + every changeAlwaysReal-time feedback
eagerBlur, then on changeAfter first blurBetter UX
blurOnly on blurAfter blurMinimal interruption
progressiveSilently, shows on blur if invalidAfter blur or validationOptimal 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.

Name (min 3 characters)
Email

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.

Name (min 3 characters)
Email

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.

Name (min 3 characters)
Email

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.

Name (min 3 characters)
Email

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.

Name (min 3 characters)
Email

useFormField for Child Components ​

useFormField is essential when:

  1. Your form fields are in child components
  2. You need the eager, blur, or progressive validation modes
  3. You want fine-grained control over individual field states

Return Values ​

PropertyTypeDescription
valueWritableComputedRef<T>The field value (use with v-model)
hasErrorComputedRef<boolean>Field has an error that should be displayed
errorsComputedRef<ValidationIssues>All validation issues
errorMessageComputedRef<string>First error message
isValidComputedRef<boolean>Field passes validation
isDirtyComputedRef<boolean>Field has been modified
isBlurredComputedRef<boolean>Field has lost focus
isValidatedComputedRef<boolean>Validation has run
isValidatingComputedRef<boolean>Async validation in progress
modeComputedRef<string>The validation mode
validationEventsComputedRef<object>Blur event handler for v-bind

Two Ways to Bind Validation Events ​

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.

vue
<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:

vue
<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:

ts
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.

vue
<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:

ts
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:

ts
// 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:

ts
// 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:

ts
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.

Username

Throttling and Debouncing ​

For expensive validations (like API calls), use throttling or debouncing to limit how often validation runs.

OptionBehaviorDefault TimeUse Case
debouncedFieldsWaits until user stops typing300msSearch fields, API calls
throttledFieldsRuns at most once per interval1000msRate-limited APIs

Name has 500ms debounce, Age has 1000ms throttle. Watch the console to see validation timing.

Name (debounced 500ms)
Age (throttled 1000ms)

Reset Form ​

Use resetForm() to reset the form to its initial state, or set resetOnSuccess to automatically reset after successful submission.

Name
Age

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.

vue
<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:

ts
const { handleSubmit } = useFormValidator({
  schema,
  options: {
    scrollToError: '.has-error', // CSS selector for error elements
    // scrollToError: false,     // Disable scrolling
  },
})

Add the matching class to your fields:

html
<MazInput
  :class="{ 'has-error': hasError }"
  :error="hasError"
/>

onError Callback ​

Handle validation failures with the onError callback:

ts
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 ​

  1. Use throttling/debouncing for expensive validations (API calls, complex logic)
  2. Prefer eager or progressive modes over aggressive for better performance
  3. Use lazy mode for simple forms with minimal validation
  4. Avoid aggressive mode on large forms - it validates every field on every change

Common Patterns ​

Multiple Forms with Identifiers ​

ts
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:

vue
<div data-interactive class="custom-input" tabindex="0">
  Custom Input
</div>

Common Pitfalls ​

PitfallSolution
Mismatched formIdentifierEnsure useFormField's formIdentifier matches useFormValidator's identifier
Validation not triggering in eager/blur/progressive modesUse ref option or v-bind="validationEvents"
TypeScript errors with useTemplateRefAdd generic type or use classic ref()
Field not found warningMake sure the field name exists in your schema

API Reference ​

useFormValidator ​

Parameters ​

ts
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 ​

ts
{
  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 ​

ts
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 ​

ts
{
  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 ​

ts
// 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:

ts
// 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:

ts
// 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 input

Conditionally 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:

  1. Ensure the ref is bound to an HTML element or a Vue component exposing $el
  2. For custom components, add the data-interactive attribute

Mismatched Form Identifiers ​

Problem: useFormField not finding the form context

Solution: Ensure identifiers match:

ts
// 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
})