When validation runs

Per-field validation triggers on change, blur, or submit: your call, per form. Sync refinements fire immediately; async refinements await.

Category
Option
Option
validateOn
Default
change

Validation timing is configured per form via the validateOn option:

const form = useForm({
  schema,
  validateOn: 'change', // default
})

Triggers

ValuePer-field validation fires on
'change' (default)Every committed write to storage. Pairs with the directive's commit cadence (per-keystroke by default, per-blur on .lazy).
'blur'When the input loses focus.
'submit'Only when handleSubmit dispatches.

The directive's commit cadence and the validation trigger are independent. With default <input v-register> and validateOn: 'change', both fire per keystroke. Switch to <input v-register.lazy> and validation still rides the commit, which now lands on change (per-blur). Switch to validateOn: 'blur' and validation skips the per-write rhythm entirely.

The same schema runs in every mode: the only thing that changes is when a refinement gets evaluated.

validateOn modes Open in playground

The same schema runs in all three. What changes is when. Type one or two characters into each, then tab away or submit, and watch when the message lands.

validateOn: 'change'

Checks on every keystroke.

No error yet
validateOn: 'blur'

Checks when the field loses focus.

No error yet
validateOn: 'submit'

Checks only when you submit.

No error yet

These panels read the raw validation result so the timing is the only variable. In real UI you gate what shows with showErrors, which adds its own reveal-on-submit rhythm.

Under validateOn: 'blur', leaving a field you never edited can't change any verdict, so Attaform skips the pass: it tracks whether the form has changed since the last validation and only revalidates when it has. Refocus a field that's showing an error, then tab away, and the error holds steady instead of blinking through 'pending' and back.

Debouncing

Pass a debounceMs to coalesce rapid bursts:

useForm({
  schema,
  validateOn: 'change',
  debounceMs: 200,
})

The field's last committed write wins after debounceMs of quiet. Useful for expensive sync refinements; required for async refinements that hit a network (otherwise every keystroke fires a request).

debounceMs is only meaningful with validateOn: 'change'. TypeScript rejects pairing it with 'blur' or 'submit', so a misconfigured form is a compile error rather than a silent runtime drop.

Sync versus async

Sync refinements (refine, superRefine with synchronous returns) run on the trigger. Async refinements (anything that returns a Promise) are awaited:

  • During typing (validateOn: 'change'), the field's form.fields.<path>.validating flips true while the Promise is in flight.
  • On submit, handleSubmit waits for every active async refinement to settle before calling the success callback.
  • form.meta.valid only flips true once every active path has resolved at least one validation pass, including the async ones. No flash-of-valid window.

Where to next