Painless Headless Form Validation for Vue with VeeValidate
Introduction
While migrating a legacy Element Plus project, I needed to find another client-side form validation solution. While working with Shadcn Vue, I discovered VeeValidate, a headless form validation tool built specifically for Vue. You can integrate it with your own UI, and using native form elements is no problem either.
- A headless form library that handles only validation logic without binding to a UI, so you can bring your own UI without being constrained by prebuilt components
- Deep integration with Vue components and the Composition API
- Shadcn Vue provides many ready-to-use wrapped components and styles
- Supports TypeScript as well as Yup, Zod, and Valibot
Basic Examples
vee-validate provides two approaches to form validation:
- Higher-order components (HOC)
- Composition API
Component
Using the ready-made Field and Form components, which render native HTML form elements by default:
<template> <Form :validation-schema="validationSchema" @submit="onSubmit"> <Field name="email" type="email" /> <ErrorMessage name="email" />
<Field name="password" type="password" /> <ErrorMessage name="password" />
<button>Submit</button> </Form></template>
<script setup>import { Form, Field, ErrorMessage } from 'vee-validate';import { toTypedSchema } from '@vee-validate/zod';import * as zod from 'zod';
const validationSchema = toTypedSchema( zod.object({ email: zod.string().min(1, { message: 'This is required' }).email({ message: 'Must be a valid email' }), password: zod.string().min(1, { message: 'This is required' }).min(8, { message: 'Too short' }), }));
function onSubmit(values) { alert(JSON.stringify(values, null, 2));}</script>We can replace the component at any time and pass its state back through slot props for use in a custom input component:
<template><Field v-model="name" type="text" name="name" v-slot="{ field }"> <Input v-bind="field"></Field><template>This is also why the Shadcn Vue form fields are wrapped like this:
<template> <FormField v-slot="{ componentField }"> <FormItem> <FormLabel>Username</FormLabel> <FormControl> <Input placeholder="shadcn" v-bind="componentField" /> </FormControl> <FormDescription /> <FormMessage /> </FormItem> </FormField></template>Composition API
Some situations require more flexible form control, such as setting initial form values asynchronously. Because initialValues is used only when the form is initialized and cannot be reactive, “multi-step forms” and “forms that need to fetch asynchronous data” often require defining the form with useForm first, then using the returned setValues method to manipulate its values—for example, to initialize values when opening a Dialog.
<template> <Dialog :open="open" @update:open="$emit('update:open', $event)"> <DialogContent> <DialogHeader class="mb-2"> <DialogTitle>Edit Foo Bar</DialogTitle> <DialogDescription class="sr-only">Edit Foo Bar</DialogDescription> </DialogHeader> <form class="flex flex-col gap-4" id="testSendForm" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="foo"> <FormItem> <FormLabel>Foo</FormLabel> <FormControl> <Input type="text" placeholder="Enter foo" v-bind="componentField" /> </FormControl> <FormMessage /> </FormItem> </FormField>
<FormField v-slot="{ componentField }" name="bar"> <FormItem> <FormLabel>Bar</FormLabel> <FormControl> <Input type="text" placeholder="Enter bar" v-bind="componentField" /> </FormControl> <FormMessage /> </FormItem> </FormField> </form>
<DialogFooter> <Button :disabled="isPending" form="testSendForm" @click="onSubmit"> {{ isSendTestMailPending ? 'Editing...' : 'Edit' }} </Button> </DialogFooter> </DialogContent> </Dialog></template>
<script setup lang="ts">
const props = defineProps<{ open: boolean; defaultFormValue: DefaultFormValue}>()const emit = defineEmits(['update:open'])const { open } = toRefs(props)const testSendSchema = z.object({ foo: z.string().min(1, 'Enter Foo'), bar: z.string().min(1, 'Enter Bar'), })
type TestSendSchema = z.infer<typeof testSendSchema>
const { handleSubmit, setValues } = useForm<TestSendSchema>({ validationSchema: toTypedSchema(testSendSchema),})
const onSubmit = handleSubmit(async (submittedValue: TestSendSchema) => { await sendTestMail({ ...submittedValue, }) emit('update:open', false)})
// Initialize the form when the Dialog openswatch(open, (isOpen) => { if (isOpen && props.defaultFormValue) { setValues({ foo: props.defaultFormValue.foo, bar: props.defaultFormValue.bar, }) }})</script>Advanced Use Cases
Multi-Step Forms
The official documentation includes a Multi-step Form Wizard example. The core concept is simply to make validation-schema dynamic and use step to switch what should be validated at each stage. The quickest and easiest option is to use the ready-made Shadcn Vue implementation.
<script setup lang="ts">const currentSchema = computed(() => { return schemas[currentStep.value];});</script>
<template><Form @submit="nextStep" :validation-schema="currentSchema" keep-values></template>Dynamically Adding Fields
Suppose you have a field where users should be able to freely change the number of entries—for example, entering anywhere from one to five links.
The <FieldArray /> component manages repeatable array fields. It is a renderless component, meaning it does not render anything itself:
<template> <Form @submit="onSubmit" :initial-values="initialValues"> <FieldArray name="links" v-slot="{ fields, push, remove }"> <div v-for="(field, idx) in fields" :key="field.key"> <Field :name="`links[${idx}].url`" type="url" />
<button type="button" @click="remove(idx)">Remove</button> </div>
<button type="button" @click="push({ id: Date.now(), name: '', url: '' })">Add</button> </FieldArray>
<button>Submit</button> </Form></template>
<script setup>// you can set initial values for those array fieldsconst initialValues = { links: [{ id: 1, url: 'https://github.com/logaretm' }],};
function onSubmit(values) { alert(JSON.stringify(values, null, 2));}</script>Alternatively, you can implement it with the Composition API:
<template> <FormField name="ips"> <FormItem> <FormLabel>Allow Source IP</FormLabel> <div class="space-y-2"> <div v-for="(field, index) in ips" :key="field.key" class="flex items-center gap-2"> <FormField v-slot="{ componentField }" :name="`ips[${index}].value`"> <FormItem class="flex-grow"> <div class="flex gap-2"> <FormControl> <Input type="text" v-bind="componentField" placeholder="Enter an IP address" /> </FormControl> <Button type="button" variant="outline" size="icon" @click="removeIp(index)" :disabled="ips.length === 1"> <icon-material-symbols:remove /> </Button> </div> <FormMessage /> </FormItem> </FormField> </div> </div> <Button type="button" variant="outline" size="sm" @click="() => addIp({ value: '' })" :disabled="ips.length >= 5"> <icon-material-symbols:add /> Add IP </Button> <FormMessage /> </FormItem> </FormField></template>
<script setup lang="ts">const { fields: ips, push: addIp, remove: removeIp } = useFieldArray<{ value: string }>('ips');
const apiSettingSchema = z.object({ ips: z.array(z.object({ value: z.ipv4({ message: 'Invalid IP format' }) })).min(1, 'At least one IP address is required'),});</script>Conclusion
I do not particularly enjoy looking up how to use these libraries, because this is the kind of information you can simply consult in the documentation when you need it—but doing so usually takes some time. This article serves as a set of quick notes from reading the documentation, recording examples of common features along with my observations.