21

Validation Modes

Understand validation modes, validation triggers, field-level overrides, and manual validation in NotForm.

NotForm separates when validation runs from how validation behaves after a field has been validated.

This gives you two controls:

  • validateOn controls which interactions trigger validation.
  • validationMode controls when an errored field is validated again after it changes.

This distinction is important because input, blur, and change are not validation modes in the current API. They are validation triggers.

The Two Validation Controls

A form can be configured with:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onChange: true,
    onInput: true,
  },

  validationMode: 'eager',

  onSubmit(values) {
    console.log(values)
  },
})

validateOn

validateOn determines which interactions can start validation.

NotForm supports five triggers:

TriggerWhen it runs
onBlurWhen a field loses focus
onChangeWhen a field's value is committed
onInputWhen a field's value changes while the user is typing
onFocusWhen a field receives focus
onMountWhen a field is mounted

The default configuration is:

{
  onBlur: true,
  onChange: true,
  onFocus: false,
  onInput: true,
  onMount: false,
}

These defaults are resolved onto form.validateOn.

console.log(form.validateOn.onBlur)
console.log(form.validateOn.onInput)

validationMode

validationMode currently has two values:

type ValidationMode = 'eager' | 'lazy'

The default is:

validationMode: 'eager'

The mode does not select an event such as blur or input. Instead, it determines how validation behaves around existing errors.

Eager Mode

With eager, a field can continue validating during subsequent changes once it is in an errored state.

This is useful when you want the error to disappear as soon as the user fixes the value.

const form = useNotForm({
  schema,

  validationMode: 'eager',

  validateOn: {
    onBlur: true,
    onInput: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

For example, imagine a required email field:

User focuses the field
        ↓
User leaves it empty
        ↓
Blur validation
        ↓
"Email is required"
        ↓
User starts typing
        ↓
Input validation continues
        ↓
Error disappears once the value validates

Eager mode is generally the better choice for forms where validation feedback should become progressively more responsive after an error has been shown.

Lazy Mode

With lazy, validation is deferred until blur or submission.

const form = useNotForm({
  schema,

  validationMode: 'lazy',

  onSubmit(values) {
    console.log(values)
  },
})

This is useful when you want a less intrusive validation experience.

A user can type through an invalid intermediate state without constantly re-running validation.

For example:

User starts typing
        ↓
Intermediate value may be invalid
        ↓
No repeated validation while editing
        ↓
User leaves the field
        ↓
Validation runs

Lazy mode is especially useful for fields where valid input requires several keystrokes before the value becomes meaningful.

Demo

Choosing Validation Triggers

The triggers and mode can be combined.

Blur Validation

Blur validation is a good default for most form fields:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onChange: false,
    onInput: false,
  },

  onSubmit(values) {
    console.log(values)
  },
})

This avoids validating every intermediate value while the user is typing.

Input Validation

Enable onInput when feedback should happen while the value is being entered:

const form = useNotForm({
  schema,

  validateOn: {
    onInput: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

This is useful for fields such as:

  • password requirements
  • usernames
  • live formatting constraints
  • short fields where immediate feedback is valuable

It can be expensive for asynchronous validation because every input interaction can start a validation run.

Change Validation

onChange validates when the value is committed.

const form = useNotForm({
  schema,

  validateOn: {
    onChange: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

This can work well with selects, checkboxes, toggles, and other controls where a committed value is more meaningful than every intermediate interaction.

Focus Validation

Focus validation is available when a field needs to validate as soon as it receives focus:

const form = useNotForm({
  schema,

  validateOn: {
    onFocus: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

It is disabled by default.

Mount Validation

onMount validates when a field is mounted.

const form = useNotForm({
  schema,

  validateOn: {
    onMount: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

This can be useful for forms whose initial state should immediately be evaluated.

It is disabled by default.

Combining Triggers

Triggers are independent.

You can enable several at once:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onChange: true,
    onInput: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

Or selectively disable one:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onChange: true,
    onInput: false,
  },

  onSubmit(values) {
    console.log(values)
  },
})

Because validateOn is partial, only the triggers you specify are overridden.

Per-Field Configuration

Individual fields can override the form-wide trigger configuration.

<NotField
  path="username"
  :validate-on="{ onInput: true }"
  v-slot="{ events }"
>
  <input
    v-model="form.values.username"
    v-bind="events"
  />
</NotField>

This lets the form keep one general strategy while giving specific fields different behavior.

For example:

Form
├── name       → blur
├── email      → blur
├── username   → input
└── password   → blur

See <NotField> for field-level configuration.

Validation on Submission

Submission always performs complete form validation.

<form @submit="form.submit">
  ...
</form>

form.submit():

  1. prevents the browser's native submission
  2. marks all existing fields as touched
  3. marks all existing fields as dirty
  4. validates the entire form
  5. aborts when validation fails
  6. calls onSubmit only when validation succeeds

This means the validation configuration cannot be used to bypass submit-time validation.

A field that has never been interacted with is still validated when the form is submitted.

Manual Validation

Validation does not have to come from field events.

Validate the Whole Form

const result = await form.validate()

A successful result contains value:

const result = await form.validate()

if ('value' in result) {
  console.log(result.value)
}

A failed result contains issues:

const result = await form.validate()

if ('issues' in result) {
  console.log(result.issues)
}

form.errors is updated to match the complete validation result.

Validate a Single Field

const result = await form.validateField('email')

validateField() runs the schema against the form values but only updates errors associated with the requested field.

Existing errors for other fields are preserved.

const result = await form.validateField('email')

if ('issues' in result) {
  console.log(result.issues)
}

This makes it useful for custom inputs and programmatic field validation.

Async Validation

NotForm supports asynchronous Standard Schema validation.

For example:

const schema = z.object({
  username: z.string().refine(
    async (value) => {
      const response = await fetch(`/api/check-username?username=${value}`,)

      const data = await response.json()

      return data.available
    },
    {
      message: 'Username is already taken',
    },
  ),
})

Because validation can be asynchronous, the form exposes:

form.isValidating

Use it to display loading state:

<p v-if="form.isValidating">
  Checking username...
</p>

isValidating remains active while validation work is still running, including concurrent validation runs.

Validation State

Validation produces several pieces of state:

form.errors
form.errorsMap
form.isValid
form.isValidating

Use errors when you need the complete StandardSchemaV1.Issue[].

Use errorsMap when you need the first message for a field.

Use isValid when you only need to know whether the form currently has active errors.

Use isValidating when displaying asynchronous validation state.

General Forms

A good general-purpose configuration is:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onChange: true,
    onInput: true,
  },

  validationMode: 'eager',

  onSubmit(values) {
    console.log(values)
  },
})

Less Intrusive Forms

Use lazy validation when you want fewer validation runs while editing:

const form = useNotForm({
  schema,

  validationMode: 'lazy',

  validateOn: {
    onBlur: true,
  },

  onSubmit(values) {
    console.log(values)
  },
})

Async-Heavy Fields

For expensive asynchronous validators, avoid unnecessarily aggressive input validation:

const form = useNotForm({
  schema,

  validateOn: {
    onBlur: true,
    onInput: false,
  },

  validationMode: 'lazy',

  onSubmit(values) {
    console.log(values)
  },
})