---
title: "Validation"
description: "How NotForm validates, when each field validates, and how to tune triggers, debounce, async rules, and stale results."
canonical_url: "https://notformdocs.vercel.app/docs/guides/validation"
---

> Index of every page on this site: [llms.txt](https://notformdocs.vercel.app/llms.txt)

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](https://notformdocs.vercel.app/docs/recipes/cross-field-validation).
3. Every field validation executes the entire schema. This matters for slow or asynchronous rules. See [Async rules and performance](#async-rules-and-performance).

## When fields validate

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

<table>
<thead>
  <tr>
    <th>
      Event
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Behavior
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        blur
      </code>
    </td>
    
    <td>
      Validates immediately
    </td>
    
    <td>
      Always marks the field touched, even if validation on blur is turned off.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        change
      </code>
    </td>
    
    <td>
      Validates
    </td>
    
    <td>
      Fires when a control commits a value, such as picking an option.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        input
      </code>
    </td>
    
    <td>
      Does not validate
    </td>
    
    <td>
      Fires while typing. Validates only if the field already has an error and the mode is <code>
        eager
      </code>
      
      .
    </td>
  </tr>
</tbody>
</table>

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()`.

```ts
const username = form.field('username', {
  debounce: 300,
  validateOn: { input: true },
  validationMode: 'lazy',
})
```

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        validateOn
      </code>
    </td>
    
    <td>
      <code>
        { blur?: boolean, change?: boolean, input?: boolean }
      </code>
    </td>
    
    <td>
      <code>
        blur
      </code>
      
       and <code>
        change
      </code>
      
       on, <code>
        input
      </code>
      
       off
    </td>
    
    <td>
      Events that trigger validation. Omitted keys keep their defaults.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        validationMode
      </code>
    </td>
    
    <td>
      <code>
        'eager' | 'lazy'
      </code>
    </td>
    
    <td>
      <code>
        'eager'
      </code>
    </td>
    
    <td>
      <code>
        eager
      </code>
      
      : once a field has errors, <code>
        input
      </code>
      
       and <code>
        change
      </code>
      
       re-validate it. <code>
        lazy
      </code>
      
      : only events enabled in <code>
        validateOn
      </code>
      
       validate.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        debounce
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
      
       (ms)
    </td>
    
    <td>
      none
    </td>
    
    <td>
      Delay for <code>
        input
      </code>
      
       and <code>
        change
      </code>
      
       validation. Blur and submit are never delayed.
    </td>
  </tr>
</tbody>
</table>

<warning>

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>`.

</warning>

## Presets

<code-group>

```ts [Validate on leave (default)]
form.field('email')
```

```ts [Instant feedback]
form.field('password', { validateOn: { input: true } })
```

```ts [Validate on submit only]
form.field('notes', {
  validateOn: { blur: false, change: false },
  validationMode: 'lazy',
})
```

```ts [Debounced async check]
form.field('username', { debounce: 400, validateOn: { input: true } })
```

</code-group>

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.

```ts
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

```ts
// 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](https://notformdocs.vercel.app/docs/guides/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.

```ts
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](https://notformdocs.vercel.app/docs/recipes/async-validation) 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.

```ts
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](https://notformdocs.vercel.app/docs/guides/errors) shows how to render what validation finds.
- [Cross-field validation](https://notformdocs.vercel.app/docs/recipes/cross-field-validation) covers confirm-password and date ranges.

## Sitemap

See the full [sitemap](https://notformdocs.vercel.app/sitemap.md) for all pages.
