21

Validation

How NotForm validates, when each field validates, and how to tune triggers, debounce, async rules, and stale results.

Rules live in your schema. NotForm decides when to run the schema and which errors to keep.

How validation works

NotForm always validates the whole form against a snapshot of the current values. What differs is which results are kept:

  • A field validation is triggered by a field event or field.validate(). The whole schema runs, but only issues at that field's exact path replace that field's errors. Other errors are untouched.
  • A form validation is triggered by form.validate() or a submit. Every error is replaced with the schema's result.

Three consequences are worth remembering:

  1. An issue reported on a parent path such as ['address'] is not shown by the address.city field, and the reverse is also true.
  2. A rule that spans fields must report its issue on the path of the field that should display it. See Cross-field validation.
  3. Every field validation executes the entire schema. This matters for slow or asynchronous rules. See Async rules and performance.

When fields validate

Each field has three trigger events. These are the defaults:

EventDefaultBehavior
blurValidates immediatelyAlways marks the field touched, even if validation on blur is turned off.
changeValidatesFires when a control commits a value, such as picking an option.
inputDoes not validateFires while typing. Validates only if the field already has an error and the mode is eager.

Together with the default eager mode, this gives the most common behavior: no errors while a user first types, an error when they leave the field, and the error clearing as soon as they fix it.

Configure a field

Pass a FieldConfig as the second argument to form.field() or form.arrayField().

const username = form.field('username', {
  debounce: 300,
  validateOn: { input: true },
  validationMode: 'lazy',
})
OptionTypeDefaultMeaning
validateOn{ blur?: boolean, change?: boolean, input?: boolean }blur and change on, input offEvents that trigger validation. Omitted keys keep their defaults.
validationMode'eager' | 'lazy''eager'eager: once a field has errors, input and change re-validate it. lazy: only events enabled in validateOn validate.
debouncenumber (ms)noneDelay for input and change validation. Blur and submit are never delayed.
The config is read once, when the field is first created. Later calls with the same path return the cached field and ignore any new config. Create each field with its config in one place, preferably in <script setup>.

Presets

form.field('email')

In the submit-only preset, blur still marks the field touched, but nothing validates until the user submits. After a failed submit, errors appear for every field.

Debounce

debounce waits for the user to pause before validating on input or change. It collapses a burst of keystrokes into one validation, which is most useful when the schema performs network calls.

const username = form.field('username', { debounce: 400 })
  • Blur and form submission are not delayed.
  • field.validate() skips any pending debounce and validates immediately.
  • Resetting the form cancels pending debounced validations.

Validate explicitly

// Whole form. Replaces all errors. Does not call onSubmit.
const result = await form.validate()
if (result.issues) {
  // invalid
} else {
  result.value // parsed output
}

// One field. Skips pending debounce. Updates only that field's errors.
const outcome = await form.field('email').validate()

form.isValid does not run validation. It only reports whether the current error list is empty. See Form state.

Programmatic changes do not validate

Writing through field.model, form.setValue(), or form.values changes data but does not validate. Validate afterward if you need fresh errors.

form.setValue('country', 'NG')
await form.field('country').validate()

Async rules and performance

Standard Schema validators can return promises, so async rules, such as "is this username taken?", work.

Because every field validation runs the whole schema, an async rule runs whenever any field validates, not only the field it belongs to. For network checks:

  • debounce the field that owns the rule;
  • cache results by value so unchanged values do not hit the network again;
  • skip the check when cheaper rules, such as minimum length, already fail.

The Async validation recipe shows all three.

Stale results

Validation is asynchronous, so values can change while a run is in flight. NotForm discards such results instead of overwriting newer state:

  • Any value change marks every in-flight validation stale. Its result is dropped and the errors stay as they were. The change's own event normally starts a fresh validation.
  • A newer validation of the same field replaces an older one.
  • A submit in progress is never invalidated by a field or manual validation. It is re-run only when a value changes underneath it.
  • Changing a reactive schema or resetting the form also marks in-flight runs stale.

You never need to manage this yourself, but it explains why a result that arrives late may not show up.

Reactive schemas

schema accepts a value, a ref, or a getter. NotForm reads it each time validation runs.

const requireTaxId = ref(false)

const form = useForm({
  schema: () => {
    return requireTaxId.value ? businessSchema : personalSchema
  },
})

Changing the schema discards in-flight validations, but it does not re-validate or change existing errors. Call form.validate() after switching if you want fresh results. Keep the input shape compatible between schemas, because the typed paths come from the schema's type.

Next steps

Copyright © 2026 NotForm
Published under MIT License