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 —
toTypedSchemaadapters 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 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
- List the features the form needs today. Field count, repeatable groups, nested objects, async rules, server error mapping, multi-step.
- 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.
- 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.
- 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.
- 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.
- Wrap either behind your own field component. Components like the one in custom form inputs with defineModel take
v-modelanderror, so switching engines later touches composables, not templates.
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.
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.