Writing Custom Validators with the Vue Composition API

When VeeValidate’s built-in rules can’t express your business logic, you need a way to run bespoke validation inside a setup() composable while still funnelling every result through the native Constraint Validation API so submit behaviour stays consistent. This page shows how to build reusable custom validators as Composition API functions that own their own reactive state, register with useField, and set native validity messages.

When to Use This Composition-API Validator Approach

Reach for a hand-written Composition API validator when the rule is specific enough that a shared schema would be awkward, but reusable enough that you want it as a use* function rather than an inline callback. Compared to the neighbouring approaches on this site:

  • Use Typed Schema Validation with VeeValidate and Zod when the whole form maps cleanly to a declarative shape and you want type inference for free.
  • Use a Composition API custom validator (this page) when a single field needs imperative logic — a checksum, a conditional rule, or a debounced remote lookup — that reads better as code than as schema config.
  • If the rule spans two inputs, layer in cross-field validation instead of duplicating state.
Composition API validator flow A composable produces a validate function, useField wires it to a field, and the result sets native validity. A custom validator function feeds VeeValidate, which feeds native validity useCustomValidator() returns validate(value) useField(name, rule) tracks errorMessage setCustomValidity() reportValidity() on submit
The composable owns the rule, VeeValidate tracks the message, and the native API gates the submit.

A useField + setCustomValidity() Minimal Implementation

The baseline keeps <form novalidate> and drives the gate with a single manual reportValidity() call. The custom validator is a plain function returning true or an error string — VeeValidate’s contract — and we mirror its result onto the native input so reportValidity() reflects the same state.

import { ref } from 'vue'
import { useField } from 'vee-validate'

// A reusable Composition API validator: a pure function of the field value.
// Returns `true` when valid, or a human-readable string when invalid.
export function useInviteCode() {
  const validate = (value: unknown): true | string => {
    if (typeof value !== 'string' || value.length === 0) {
      return 'An invite code is required.'
    }
    // Bespoke rule: 4 groups of 4 uppercase alphanumerics.
    if (!/^[A-Z0-9]{4}(-[A-Z0-9]{4}){3}$/.test(value)) {
      return 'Codes look like ABCD-1234-EFGH-5678.'
    }
    return true
  }

  // useField registers the rule and exposes reactive field state.
  const field = useField<string>('inviteCode', validate)
  return field
}
import { computed, ref } from 'vue'
import { useInviteCode } from './useInviteCode'

export default {
  setup() {
    const formEl = ref<HTMLFormElement | null>(null)
    const { value, errorMessage, validate } = useInviteCode()

    // Keep the native validity object in sync with VeeValidate's message,
    // so the manual Constraint Validation call below sees the same truth.
    const syncNative = (input: HTMLInputElement) => {
      input.setCustomValidity(errorMessage.value ?? '')
    }

    const onSubmit = async (e: Event) => {
      // Run VeeValidate first so errorMessage is fresh...
      await validate()
      const input = formEl.value!.elements.namedItem('inviteCode') as HTMLInputElement
      syncNative(input)
      // ...then a single native gate decides whether the submit proceeds.
      if (!formEl.value!.reportValidity()) {
        e.preventDefault()
        return
      }
      // proceed with a valid payload
    }

    return { formEl, value, errorMessage, onSubmit }
  },
}
<form ref="formEl" novalidate @submit="onSubmit">
  <label for="inviteCode">Invite code</label>
  <input id="inviteCode" name="inviteCode" v-model="value" />
  <p aria-live="polite">{{ errorMessage }}</p>
  <button type="submit">Join</button>
</form>

useField Custom-Validator: Option Reference

These are the values you actually configure when wiring a Composition API validator to a field. See custom validity messages for how the native string surfaces.

Option Type Default Purpose
rules (2nd arg) (v) => true | string | Promise<...> required The validator function; a string return is the error message.
validateOnValueUpdate boolean true Whether each v-model change re-runs the rule.
validateOnMount boolean false Run the rule once on mount to surface initial errors.
label string field name Human label interpolated into generated messages.
bails boolean true Stop at the first failing rule when composing several.
syncVModel boolean false Two-way bind value to a parent v-model.

Verification Steps

import { test, expect } from '@playwright/test'

test('invite code gate blocks a bad value', async ({ page }) => {
  await page.goto('/join')
  await page.getByLabel('Invite code').fill('nope')
  await page.getByRole('button', { name: 'Join' }).click()
  // Native validity should still be invalid, so the URL never changes.
  await expect(page).toHaveURL(/\/join$/)
  const msg = await page.getByLabel('Invite code').evaluate(
    (el: HTMLInputElement) => el.validationMessage,
  )
  expect(msg).toContain('ABCD-1234')
})

Edge Cases & Failure Modes

Stale native message after a fix. If you call setCustomValidity() only on failure, a once-invalid input stays invalid forever. Always set it to the current errorMessage.value ?? '' on every run so a corrected value clears the native flag.

Async races on remote lookups. When your validator performs asynchronous server checks, a slow earlier request can resolve after a newer keystroke and overwrite a fresh result. Guard each call with an AbortController and ignore aborted responses so only the latest lookup writes validity.

Message set but focus lost. Setting validity without moving the caret leaves keyboard users stranded. Pair the gate with focus management after a failed submit so the first invalid field receives focus after reportValidity() returns false.

Debouncing a Remote Check Inside the Composable

The AbortController guard above stops stale responses from clobbering a fresh result, but on its own it still fires a request per keystroke. For a uniqueness check — an email or username that must not already exist — you also want to debounce the network call so you send one request after the user pauses, not one per character. Because the validator is a composable, both concerns live in the same closure: the timer handle and the controller are private state that no consuming component can accidentally reset.

The subtlety is that VeeValidate’s validateOnValueUpdate still runs your rule synchronously on every change. So the rule returns quickly for the local checks (format, length) and only schedules the remote check, resolving the promise once the debounced request settles. Returning a rejected-but-caught promise from an aborted call would surface as an error, so an aborted lookup must resolve to true — treat “we cancelled this because a newer value arrived” as “not this run’s problem”.

import { useField } from 'vee-validate'

// A composable that debounces a uniqueness lookup and cancels in-flight
// requests. Local checks fail fast; only well-formed values hit the network.
export function useUniqueEmail(delay = 300) {
  let timer: ReturnType<typeof setTimeout> | undefined
  let controller: AbortController | undefined

  const validate = (value: unknown): true | string | Promise<true | string> => {
    if (typeof value !== 'string' || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) {
      return 'Enter a valid email address.'
    }

    // Cancel any pending timer and any request already in flight.
    clearTimeout(timer)
    controller?.abort()
    controller = new AbortController()
    const { signal } = controller

    return new Promise<true | string>((resolve) => {
      timer = setTimeout(async () => {
        try {
          const res = await fetch(
            `/api/email-available?value=${encodeURIComponent(value)}`,
            { signal },
          )
          const { available } = (await res.json()) as { available: boolean }
          resolve(available ? true : 'That email is already registered.')
        } catch (err) {
          // An aborted request means a newer keystroke superseded this one.
          // Resolve valid so the stale run never writes an error message.
          if ((err as DOMException).name === 'AbortError') resolve(true)
          else resolve('Could not verify email right now.')
        }
      }, delay)
    })
  }

  return useField<string>('email', validate, { validateOnValueUpdate: true })
}

Note the ordering: the format regex short-circuits before any timer is armed, so a half-typed address never reaches fetch. This keeps the server free of garbage requests and means the debounce delay only ever applies to values that are at least structurally plausible.

Reacting to Another Field with watch

A Composition API validator can depend on reactive state that is not the field’s own value — a “confirm password” that must match the first field, or a shipping rule that only applies when a checkbox is on. Rather than reading the other field inside the rule (which VeeValidate will not re-trigger when the dependency changes), capture it as a ref in the composable and re-run validation when it moves. VeeValidate exposes validate() on the returned field, so a watch on the dependency gives you a precise re-check without touching the DOM.

import { watch, type Ref } from 'vue'
import { useField } from 'vee-validate'

// `original` is the ref this field must agree with (e.g. the password).
export function useConfirmation(original: Ref<string>) {
  const field = useField<string>('confirmation', (value) =>
    value === original.value ? true : 'The two values must match.',
  )

  // When the field it mirrors changes, re-validate so a previously green
  // confirmation turns red the moment the original diverges.
  watch(original, () => {
    if (field.meta.touched) field.validate()
  })

  return field
}

Gating the re-check behind meta.touched avoids flashing an error on a field the user has not visited yet — the confirmation only turns red once it has been engaged and the value it depends on later changes underneath it.

Frequently Asked Questions

Can a Composition API validator be async?

Yes. Return a Promise<true | string> from the rule and VeeValidate awaits it. Because the native gate is a single await validate() before reportValidity(), wrap remote work in an AbortController to drop stale responses.

Why keep novalidate if VeeValidate already validates?

novalidate suppresses the browser's automatic bubbles so you control exactly when validation fires. You then trigger a single manual reportValidity(), giving one consistent gate whether the failure came from a native constraint or your custom rule.

Should I use a validator function or a Zod schema?

For one imperative field rule, a Composition API function is lighter. When several fields share a shape or you want inferred types, prefer Zod schema validation and let the resolver map errors back to fields.

Where should the debounce timer and AbortController live?

Inside the composable's closure, not in component state. Keeping the timer and controller as private module-local variables in the use* function means every instance owns its own scheduling, nothing in a template can reset them mid-flight, and they are naturally garbage-collected when the field unmounts.

Why gate the confirmation re-check behind meta.touched?

Without it, editing the original password would immediately paint the empty, never-visited confirmation field red. Checking field.meta.touched ensures the dependency-driven re-validation only fires once the user has actually engaged the mirrored field, so errors appear in response to their edits rather than pre-emptively.

← Back to Vue VeeValidate Validation