Skip to content

useFormValidator

Vue composables for form validation with Valibot - 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 using Valibot.

  • 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

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

Email
Password

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.

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>

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:

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

Use Valibot's pipeAsync and checkAsync for async validations like checking username availability:

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,                                    // Valibot validation schema (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>,       // Template ref for blur detection
    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
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>
  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

Element Not Found Warning

Problem: No element found for ref in field 'name'

Solutions:

  1. Ensure the ref is bound to an HTML element or Vue component
  2. Make sure the component has a $el property
  3. For custom components, add 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
})