skip to content

In a VeeValidate 4 sign-up form validated by one Zod schema, why does an async username-availability refine fire on keystrokes in other fields, and how do you contain it?

level: seniorimportance: nice to knowfreq 16%

answer

  1. a form schema runs whole
  2. batched, not scoped
  3. only the latest result lands
  4. cache the lookup by value
  5. pending while it waits

basics

~20 s

With a form-level schema, validating any field parses the whole schema, so an async username refine runs whenever email or password validate. Cache the lookup per username, make the field lazy, and show meta.pending while it runs.

solid answer

~40 s

When `useForm` has a `validationSchema`, a field's validation goes through that schema: the Zod adapter runs `safeParseAsync` on the whole object, batched in short windows, and VeeValidate then applies the result to fields already validated. So every keystroke in `email` or `password` also runs the username `refine`, and each run can send a request. VeeValidate only applies the latest run's result, so stale responses do not overwrite newer ones, but the requests still go out. Contain it by memoising the check by username inside the refine, making the username field lazy with `validateOnModelUpdate: false`, and showing `meta.pending` or `isValidating` while it waits. Field-level rules passed to `useField` are not consulted when a form schema exists, so moving the check there alone does not help.

code

ts · 27 lines
ts
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const availability = new Map<string, Promise<boolean>>()

function isUsernameFree(name: string): Promise<boolean> {
  let check = availability.get(name)
  if (!check) {
    check = fetch(`/api/usernames/${encodeURIComponent(name)}`)
      .then((res) => res.json() as Promise<{ available: boolean }>)
      .then((body) => body.available)
    availability.set(name, check)
  }
  return check
}

export const signUpSchema = toTypedSchema(
  z.object({
    username: z.string().min(3).refine(isUsernameFree, 'That username is taken'),
    email: z.string().email(),
    password: z.string().min(8),
    confirm: z.string(),
  }).refine((d) => d.password === d.confirm, {
    message: 'Passwords do not match',
    path: ['confirm'],
  }),
)

go deeper

for a junior

Recall that an async rule returns a promise, that the form waits for it, and that pending flags show it is running.

for a middle

Explain that a form schema is parsed as a whole on each field validation, batched, with messages shown only for validated fields.

for a senior

Diagnose the request storm, know that field rules are ignored under a form schema and only the latest run applies, and contain it with a value-keyed cache.

for a principal

Decide which checks belong in the shared schema and which are server-owned, and set a pattern for async lookups that every form reuses.

## How a form schema is run In VeeValidate 4 a form can validate in two ways: **field-level rules**, passed to each `useField`, or one **form-level schema**, passed to `useForm` as `validationSchema`. With a schema, the form owns validation: 1. A field asks to validate, for example because its model changed. 2. The form runs the **entire schema** over the current values. With `toTypedSchema` from `@vee-validate/zod`, that is `safeParseAsync`, so async refinements are awaited. 3. Runs requested within a few milliseconds of each other are batched into one parse. 4. The results update every field's `valid` flag, and error messages are set only for fields already validated. In the source, a field's own validator is only used when the form has no schema: `useField` checks for the form's schema first. Rules passed to `useField` in a schema-driven form are not consulted. ## Why the username check runs everywhere The sign-up schema holds `username: z.string().min(3).refine(isUsernameFree, 'That username is taken')`. Because step 2 parses the whole object, that refine runs on every validation of **any** field. With the default triggers, `defineField` validates on each model update, so typing a password sends availability requests for an unchanged username. ## What VeeValidate already protects - **Ordering.** Validation runs are wrapped so that only the latest run's result is applied; a slow response for an old value cannot overwrite a newer result. - **Batching.** Several fields validating together share one schema parse. - **Pending state.** Field `meta.pending`, form `meta.pending` and `isValidating` are true while a run is in flight, and `handleSubmit` waits for it. What it does not do is skip unchanged fields: the schema runs as a whole, and the refine is just a function inside it. ## Containing the request volume | Technique | Effect | |---|---| | memoise `isUsernameFree` by value | re-runs for an unchanged username reuse one promise | | `defineField('username', { validateOnModelUpdate: false })` | the username's own typing validates on blur and change only | | gate on the synchronous rules | skip the lookup in the refine until the value is long enough | | re-check on the server at submit | the final authority, since availability can change after the check | Memoisation is the one that addresses the cause: other fields still trigger the refine, but it resolves from the cache without a request. Clear or expire entries if usernames can be claimed while the form is open. ## Password confirmation in the same schema A confirm field is typically checked with an object-level `refine((d) => d.password === d.confirm, { path: ['confirm'] })`. The VeeValidate docs flag a known Zod behaviour: object-level `refine` and `superRefine` do not run while some of the object's keys are missing. Seeding `initialValues` for every field helps by keeping the keys present from the start. Global rules offer another route: `@vee-validate/rules` has a `confirmed` rule, used as `confirmed:@password` in a field's rules string. ## Verifying the fix A request storm is easy to miss in manual testing, because every response is correct. Make it visible: 1. In a component test, stub `fetch` and count calls while typing ten characters into the password field; with the cache, the count should not grow after the first username check. 2. Type a username, change it, and change it back; the cache should answer the third value without a new request. 3. Delay the stubbed response for the first username and resolve it after the second; the field must show the second result, which is the latest-run rule at work. 4. Submit while a check is pending and assert that the success callback runs only after the check settles. ## Mistakes worth catching - Moving the check into `useField`'s rules while keeping the form schema: those rules are not used. - Adding a debounce inside the refine without caching: fewer requests, but still one per burst of typing in unrelated fields. - Disabling submit until `meta.valid`: an unavailable username resolves late, and `handleSubmit` already waits for pending validation.

  • What does the user see while the availability check is in flight?
    The username field's `meta.pending` and the form's `meta.pending` are true, and `isValidating` from `useForm` is true during the schema run. Show a small 'checking' state next to the field from the field's pending flag, and leave submit enabled: `handleSubmit` waits for validation before deciding.
  • Why is the client check not enough on its own?
    Another user can claim the name between the check and the submit, and a client can skip the check entirely. The server must validate availability when it creates the account, and its answer is mapped back onto the username field as an error.

saying these in an interview costs you the question

  • A refine on the username only runs when the username field changes.
  • A slow old response can overwrite the newest username result.
  • Moving the check into useField rules while keeping the form schema scopes it to one field.
  • Setting validateOnModelUpdate: false on username stops the refine when typing a password.
  • The client availability check makes a server-side check unnecessary.