Validation
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:
- An issue reported on a parent path such as
['address']is not shown by theaddress.cityfield, and the reverse is also true. - A rule that spans fields must report its issue on the path of the field that should display it. See Cross-field validation.
- 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:
| Event | Default | Behavior |
|---|---|---|
blur | Validates immediately | Always marks the field touched, even if validation on blur is turned off. |
change | Validates | Fires when a control commits a value, such as picking an option. |
input | Does not validate | Fires 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',
})
| Option | Type | Default | Meaning |
|---|---|---|---|
validateOn | { blur?: boolean, change?: boolean, input?: boolean } | blur and change on, input off | Events 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. |
debounce | number (ms) | none | Delay for input and change validation. Blur and submit are never delayed. |
<script setup>.Presets
form.field('email')
form.field('password', { validateOn: { input: true } })
form.field('notes', {
validateOn: { blur: false, change: false },
validationMode: 'lazy',
})
form.field('username', { debounce: 400, validateOn: { input: true } })
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
- Errors shows how to render what validation finds.
- Cross-field validation covers confirm-password and date ranges.