21

Async validation

Check values against a server, such as username availability, with debounce, caching, and loading state.

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

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

Why each piece is there

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

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.

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.

Copyright © 2026 NotForm
Published under MIT License