Async validation
Use z.refine(async …) anywhere in your schema — uniqueness checks,
allow-lists, server availability. Attaform awaits the result before
dispatching your submit handler.
Async refinements
import { z } from 'zod'
import { useForm } from 'attaform/zod'
const signupSchema = z.object({
email: z.email().refine(async (value) => {
const res = await fetch(`/api/email-available?e=${encodeURIComponent(value)}`)
const { available } = (await res.json()) as { available: boolean }
return available
}, 'Email already registered'),
password: z.string().min(8),
})
const { handleSubmit, errors } = useForm({ schema: signupSchema, key: 'signup' })
That's all the wiring you need. handleSubmit validates, waits for
any async refinement to settle, and then dispatches to your callback
(or populates errors if validation fails).
Live "checking…" UI with validate()
validate() returns a reactive ref whose value carries a pending
flag — use it to show a spinner while async validation is in flight.
<script setup lang="ts">
const { validate } = useForm({ schema: signupSchema, key: 'signup' })
const status = validate()
</script>
<template>
<p v-if="status.pending">Checking…</p>
<p v-else-if="status.success">Looks good!</p>
<ul v-else>
<li v-for="e in status.errors" :key="e.message">{{ e.message }}</li>
</ul>
</template>
When the form mutates, pending flips back to true and the
library re-validates. If the user types faster than the server can
answer, older responses are discarded — your ref only ever shows the
latest result.
One-shot validation with validateAsync(path?)
For non-submit flows — a "continue" button on a wizard, a manual
re-check after a server round-trip — await a single validation run:
const { validateAsync, errors } = useForm({ schema, key: 'signup' })
async function onContinueClick() {
const result = await validateAsync()
if (!result.success) return
goToNextStep()
}
validateAsync(path) validates the subtree at path; validateAsync()
validates the whole form.
Disabling buttons during validation
form.meta.isValidating is a reactive boolean that's true while
ANY validation run is in flight — submit, reactive validate(), or
validateAsync. Gate UI off it:
<button :disabled="form.meta.isValidating || form.meta.isSubmitting">Continue</button>
Combining with server errors
Async validation covers what the schema knows. Real server
errors (payment declined, coupon expired) still arrive after a real
POST — parse them via parseApiErrors and write them with
setFieldErrors in your catch. Wire entries are
{ message, code } (both required); see the
server-errors recipe for the full payload
shape.
import { parseApiErrors } from 'attaform'
const onSubmit = handleSubmit(async (values) => {
try {
await $fetch('/api/signup', { method: 'POST', body: values })
} catch (err) {
if (err.statusCode === 422) {
const result = parseApiErrors(err.data, { formKey: form.key })
if (result.ok) {
setFieldErrors(result.errors)
focusFirstError({ preventScroll: true })
}
}
}
})
form.meta.isSubmitting stays true across the full handler
(validation + server round-trip), so UI gated on it works without
extra wiring.
Cross-field validation
Use zod's sync .refine for rules that span fields:
const schema = z
.object({
password: z.string().min(8),
passwordConfirmation: z.string(),
})
.refine((data) => data.password === data.passwordConfirmation, {
message: 'Passwords do not match',
path: ['passwordConfirmation'],
})
Sync and async refines work side by side — the adapter runs both in order.
Discriminated unions
Let one field decide the shape of the rest:
const schema = z.discriminatedUnion('type', [
z.object({ type: z.literal('card'), number: z.string().min(16) }),
z.object({ type: z.literal('bank'), routing: z.string().length(9) }),
])
Both Zod adapters pick the active branch from the discriminator's current value and validate against only that branch.
Seeding the form from an async source
useForm itself runs synchronously — for "fetch the user's profile,
then show the form pre-filled", seed inside onMounted:
const form = useForm({ schema, key: 'profile' })
onMounted(async () => {
const profile = await $fetch('/api/profile')
form.reset(profile)
})
reset(next) applies next over the schema's defaults — same
precedence rules as the defaultValues option on useForm.