---
title: "Async validation"
description: "Check values against a server, such as username availability, with debounce, caching, and loading state."
canonical_url: "https://notformdocs.vercel.app/docs/recipes/async-validation"
---

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

Validators that support asynchronous rules work with NotForm, because validation is already asynchronous. The thing to design around is cost: **every field validation runs the entire schema**, so an async rule runs whenever any field validates.

## Example: username availability

<code-collapse>

```vue
<script setup lang="ts">
import { Field, FieldError, Form, useForm } from 'notform'
import { z } from 'zod'

// cache the promise per value so unchanged values never hit the network twice
const usernameLookups = new Map<string, Promise<boolean>>()

function isUsernameTaken(username: string): Promise<boolean> {
  let usernameLookup = usernameLookups.get(username)

  if (!usernameLookup) {
    try {
      const response = await fetch(`/api/usernames/${encodeURIComponent(username)}`)
      const responseToJson = await response.json() as { taken: boolean }

      usernameLookup = responseToJson.taken
    } catch {
      // Handle the error as needed
    }

    usernameLookups.set(username, usernameLookup)
  }

  return usernameLookup
}

const schema = z.object({
  email: z.email('Enter a valid email address.'),
  username: z.string()
    .min(3, 'Use at least 3 characters.')
    .refine(async (value) => {
      // skip the request when a cheaper rule already fails
      if (value.length < 3) {
        return true
      }

      const checkIfUsernameIsTaken = await isUsernameTaken(value)
      return !checkIfUsernameIsTaken
    }, 'That username is taken.'),
})

const form = useForm({
  initialValues: { email: '', username: '' },
  schema,
})

const username = form.field('username', {
  debounce: 400,
  validateOn: { input: true },
})
</script>

<template>
  <Form
    :form="form"
    novalidate
  >
    <Field
      v-slot="{ field }"
      :field="username"
    >
      <label :for="field.path">Username</label>

      <input
        :id="field.path"
        v-model="field.model"
        v-bind="field.events"
        autocomplete="username"
      >

      <span
        v-if="field.isValidating"
        role="status"
      >Checking...</span>

      <FieldError :errors="field" />
    </Field>

    <Field
      v-slot="{ field }"
      :field="form.field('email')"
    >
      <label :for="field.path">Email</label>

      <input
        :id="field.path"
        v-model="field.model"
        v-bind="field.events"
        type="email"
      >

      <FieldError :errors="field" />
    </Field>

    <button
      type="submit"
      :disabled="form.isSubmitting"
    >
      Create account
    </button>
  </Form>
</template>
```

</code-collapse>

## Why each piece is there

<table>
<thead>
  <tr>
    <th>
      Piece
    </th>
    
    <th>
      Reason
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        debounce: 400
      </code>
    </td>
    
    <td>
      Waits for a pause in typing instead of validating on every keystroke. Blur and submit are never delayed.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        validateOn: { input: true }
      </code>
    </td>
    
    <td>
      Validates while typing, not only on blur.
    </td>
  </tr>
  
  <tr>
    <td>
      The promise cache
    </td>
    
    <td>
      The email field's validation also runs the username rule. The cache makes that free for unchanged values.
    </td>
  </tr>
  
  <tr>
    <td>
      The early <code>
        return true
      </code>
    </td>
    
    <td>
      Avoids a request for values the cheaper <code>
        min(3)
      </code>
      
       rule already rejects. The <code>
        min
      </code>
      
       message is still reported by that rule.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        field.isValidating
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
      
       while any validation that concerns this field runs. Use it for the spinner.
    </td>
  </tr>
</tbody>
</table>

## Stale results

If the user types again while a lookup is in flight, the old result is discarded and cannot overwrite newer errors. Any value change makes every in-flight validation stale, and the change's own event starts a fresh one. See [Validation](https://notformdocs.vercel.app/docs/guides/validation#stale-results).

## Submit still validates everything

Submitting re-runs the whole schema, including the async rule, so a username that became taken after the last check is still caught. If the cache could hold outdated answers, clear it before submit or key it by time.

## Cache invalidation

The simple `Map` above lives for the component's lifetime. If availability can change during a session, expire entries after a short time, or clear the cache in `onSubmit`'s failure path.

## Sitemap

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