Async validation
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
| Piece | Reason |
|---|---|
debounce: 400 | Waits 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 cache | The email field's validation also runs the username rule. The cache makes that free for unchanged values. |
The early return true | Avoids a request for values the cheaper min(3) rule already rejects. The min message is still reported by that rule. |
field.isValidating | true 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.