.md

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.

That is separate from when a person sees the result. An error is a property of the schema, not of the interaction, so field.firstError answers as soon as the mode validates. field.showErrors is the display decision layered on top, and it waits for a submit attempt or a blur after interaction whatever validateOn says. Each panel below reports both.

validateOn modes Open in playground

The same schema runs in all three. What changes is when. Each starts with a valid handle: shorten one to a character or two, then tab away or submit. Each panel reports two things, and they move at different moments.

validateOn: 'change'

Checks on every keystroke.

firstError: the schema's verdict

showErrors: what you would render

validateOn: 'blur'

Checks when the field loses focus.

firstError: the schema's verdict

showErrors: what you would render

validateOn: 'submit'

Checks only when you submit.

firstError: the schema's verdict

showErrors: what you would render

An error is a property of the schema, not of the interaction. firstError answers as soon as that panel's mode validates, which is what these three columns differ on. showErrors is the separate decision of whether the user should see it yet, and it waits for a blur or a submit in every column. Bind showErrors in real UI: the reveal rhythm comes with it, and the verdict stays readable whenever you need it.

Under validateOn: 'blur', leaving a field you never edited can't change any verdict, so Attaform skips the pass. The comparison is per path, not per form: the blurred path's own subtree is measured against what was last validated there, so an edit somewhere else on the form does not make this blur re-run. Refocus a field that's showing an error, then tab away, and the error holds steady instead of blinking through 'pending' and back.

One blur always runs regardless: the first one after the user actually edits the field. Type into a field, delete it back to what was there, and tab away, and that pass still fires even though the value is unchanged.

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