Vue teams tend to reach for VeeValidate by default, or to reject libraries on principle and hand-roll everything — and both choices go wrong in predictable ways: the library is adopted for a three-field login and fought over its conventions, or the composable grows field arrays, async cancellation and error mapping until it is a worse copy of the library.

The Vue composition API form adapters topic defines what an adapter must do. This page compares the two ways of getting one — VeeValidate’s useForm/useField and a small custom composable — on the same form, and names the features that should tip the decision.


Context and prerequisites

VeeValidate v4 provides, via the composition API:

  • useForm — form-level values, errors, meta (dirty, touched, valid, pending), handleSubmit, resetForm, setFieldError.
  • useField / defineField — per-field value, errors, meta, and bindings for inputs or components.
  • useFieldArray — repeatable groups with stable keys.
  • Schema integration — toTypedSchema adapters for Zod, Yup and Valibot, plus Standard Schema support.
  • Async validation handling — pending state and ordering of results.

A custom composable written for one app can do the subset it needs in 100–200 lines, with no conventions to learn. The question is which subset you need, now and in a year.

The same capabilities, library versus composable Per-field value, error and touched state are provided by VeeValidate and are simple to write in a composable. Schema integration is provided through typed schema adapters and is simple to write with safeParse. Field arrays with stable keys are provided by VeeValidate and are moderate work to write correctly. Async validation with pending state and stale result handling is provided and is moderate work to write. Nested object paths and array error mapping are provided and take real effort to write. Devtools integration is provided only by the library. Capability VeeValidate Custom composable field value, error, touched built in simple schema integration toTypedSchema simple with safeParse field arrays, stable keys useFieldArray moderate; easy to get wrong async rules, pending, stale results built in moderate nested paths, array error mapping built in real effort devtools inspector Vue devtools plugin none

The core pattern: the same form, two ways

// Shared schema
import { z } from "zod";
export const Contact = z.object({
  name: z.string().trim().min(1, "Enter your name."),
  email: z.string().trim().email("Enter an email like [email protected]."),
});
export type ContactValues = z.infer<typeof Contact>;
// A) VeeValidate
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";

export function useContactFormVee() {
  const { defineField, errors, handleSubmit, meta } = useForm({
    validationSchema: toTypedSchema(Contact),
    initialValues: { name: "", email: "" },
  });
  const [name, nameAttrs] = defineField("name", { validateOnModelUpdate: false }); // validate on blur
  const [email, emailAttrs] = defineField("email", { validateOnModelUpdate: false });
  const onSubmit = handleSubmit(async (values) => { await save(values); });
  return { name, nameAttrs, email, emailAttrs, errors, meta, onSubmit };
}
// B) Custom composable — the same behaviour, spelled out
import { computed, reactive } from "vue";

export function useContactForm() {
  const values = reactive<ContactValues>({ name: "", email: "" });
  const touched = reactive<Record<keyof ContactValues, boolean>>({ name: false, email: false });
  const submitted = reactive({ value: false });

  const parsed = computed(() => Contact.safeParse(values));
  const allErrors = computed(() => {
    const out: Partial<Record<keyof ContactValues, string>> = {};
    if (!parsed.value.success) for (const i of parsed.value.error.issues) out[i.path[0] as keyof ContactValues] ??= i.message;
    return out;
  });
  // Only reveal errors for touched fields or after submit (timing policy lives here).
  const errors = computed(() => {
    const out: Partial<Record<keyof ContactValues, string>> = {};
    for (const k of Object.keys(values) as (keyof ContactValues)[]) {
      if (touched[k] || submitted.value) out[k] = allErrors.value[k];
    }
    return out;
  });
  const blur = (k: keyof ContactValues) => { touched[k] = true; };
  async function onSubmit() {
    submitted.value = true;
    if (parsed.value.success) await save(parsed.value.data);
  }
  return { values, errors, blur, onSubmit };
}
declare function save(v: ContactValues): Promise<void>;

For this form, B is about as long as A plus its imports, has no dependency, and its behaviour is readable in one screen. Add three repeatable groups, a nested address and an async email check, and B roughly quadruples while A grows by a few lines.


Step-by-step walkthrough

  1. List the features the form needs today. Field count, repeatable groups, nested objects, async rules, server error mapping, multi-step.
  2. List the features the next three forms will need. Library adoption is a codebase decision, not a per-form one; one complex form in the roadmap usually settles it.
  3. Prototype the hardest form both ways. Not the login form. The one with field arrays and async checks will expose the true cost of the composable.
  4. Measure render behaviour. Vue’s fine-grained reactivity makes both approaches cheap per keystroke for normal forms; confirm with the Vue devtools timeline rather than assuming.
  5. Keep the schema library-independent. Whichever you choose, a plain Zod (or Standard Schema) object can move between them, as in standard schema for library-agnostic forms.
  6. Wrap either behind your own field component. Components like the one in custom form inputs with defineModel take v-model and error, so switching engines later touches composables, not templates.
Library or composable? If the app has repeatable field groups, choose VeeValidate because stable keys and index-mapped errors are easy to get wrong by hand. If it has async validation that must handle pending and stale results, choose VeeValidate or plan real time for it. If there are nested objects whose errors must map to deep paths, choose the library. Otherwise, for a handful of flat forms, a custom composable is simpler to read and has no dependency. Repeatable field groups? VeeValidate yes no Async rules with pending and stale results? VeeValidate, or budget real time yes no Deeply nested error paths? VeeValidate yes no Custom composable A few flat forms: less to learn, no dependency.

The costs that do not show up in a prototype

Two costs are easy to miss when comparing a prototype. The first is team knowledge: a library brings documentation, a community and conventions that new team members may already know, while a composable’s behaviour lives only in its source and in the heads of whoever wrote it. The second is edge-case accretion: a composable starts at a hundred lines, and every production bug — a stale async result, a reset that forgot touched flags, an array error on the wrong row — adds a special case. After a year, many hand-rolled form layers are a partial reimplementation of a library, without its tests.

The opposite cost is real too. A library’s defaults encode someone else’s UX decisions, and bending them to your error timing, your summary pattern or your server error model can take more code than writing the behaviour directly. The prototype of your hardest form is where you find out which cost is larger for your team.


Failure modes and edge cases

1. Fighting the library’s timing defaults

VeeValidate validates on model update by default, so errors appear as the user types. Configure validateOnModelUpdate: false (or the global configure options) to validate on blur, matching reward early, punish late.

2. A composable that recomputes everything

computed(() => Schema.safeParse(values)) reparses the whole form on every change. That is fine for tens of fields; for hundreds, validate per field on blur and the whole form on submit.

3. Two sources of truth

Mixing VeeValidate fields with ad-hoc reactive state for some fields splits validity across two systems, so meta.valid lies. Either register every field with the library or keep the library out of that form.

4. Server errors

Both approaches need a path to put server errors onto fields: setFieldError in VeeValidate, a separate server-error map merged by precedence in the composable, as in merging errors from client, schema and server validators.

5. Migrating later

Moving from composable to library is easiest when templates use your own field components and composables return the same shape (value, error, blur). Moving the other way is rarely worth it.

Lines of form logic as requirements grow Rough orders of magnitude from building the same forms. A flat three-field form takes about 40 lines with a composable and about 25 with VeeValidate. Adding an async email check takes about 90 lines with a composable and about 35 with the library. Adding a field array and nested address takes about 220 lines with a composable and about 60 with the library. flat form: composable 40 lines flat form: VeeValidate 25 lines + async check: composable 90 lines + async check: VeeValidate 35 lines + array, nested: composable 220 lines + array, nested: VeeValidate 60 lines Orders of magnitude, not a benchmark: the gap grows with the features the library already implements.

Verification checklist


Frequently Asked Questions

Is VeeValidate heavy?

It adds a moderate amount to the bundle and tree-shakes the parts you do not import, plus the size of your schema library adapter. For an app with several non-trivial forms, it is small relative to the code it replaces. For a marketing site with one contact form, it is probably unnecessary.

What about FormKit or other Vue form libraries?

FormKit takes a different approach — schema-driven components that render inputs for you — which is attractive for form-heavy admin apps and constraining for bespoke designs. The same decision process applies: prototype your hardest form in it.

Can I start with a composable and adopt the library later?

Yes, if templates talk to field components and composables expose a stable shape. Keep the composable’s API close to useField (value, errorMessage, handleBlur) and the switch is mostly mechanical.


Related

← Vue Composition API Form Adapters