Patterns
Common patterns and best practices for building forms with Vue Hook Form.
Form Organization
Separate Schema Definition
Keep schemas in separate files for reusability:
// schemas/user.ts
import { z } from 'zod'
export const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.email('Invalid email'),
role: z.enum(['admin', 'user', 'guest']),
})
export type UserFormData = z.infer<typeof userSchema><!-- UserForm.vue -->
<script setup>
import { useForm } from '@vuehookform/core'
import { userSchema, type UserFormData } from '@/schemas/user'
const form = useForm({ schema: userSchema })
</script>Composable Wrapper
Create a composable for forms you use multiple times:
// composables/useUserForm.ts
import { useForm } from '@vuehookform/core'
import { userSchema } from '@/schemas/user'
export function useUserForm(defaultValues?: Partial<UserFormData>) {
return useForm({
schema: userSchema,
mode: 'onBlur',
defaultValues: {
name: '',
email: '',
role: 'user',
...defaultValues,
},
})
}Error Handling
Centralized Error Display
Create a consistent error component:
<!-- FormError.vue -->
<script setup>
defineProps<{
message?: string
show?: boolean
}>()
</script>
<template>
<Transition name="fade">
<p v-if="message && show !== false" class="form-error" role="alert">
{{ message }}
</p>
</Transition>
</template>Error Summary
Show all errors at once:
<script setup>
import { computed } from 'vue'
const { formState } = useForm({ schema })
const errorMessages = computed(() => {
return Object.entries(formState.value.errors)
.filter(([, msg]) => msg)
.map(([field, msg]) => ({ field, message: msg }))
})
</script>
<template>
<div v-if="errorMessages.length" class="error-summary">
<h3>Please fix the following errors:</h3>
<ul>
<li v-for="err in errorMessages" :key="err.field">
<strong>{{ err.field }}:</strong> {{ err.message }}
</li>
</ul>
</div>
</template>Server Integration
For server error handling patterns, see Async Patterns - Server Error Integration.
Optimistic Updates
Update UI before server confirms:
const onSubmit = async (data) => {
// Optimistically update UI
showSuccessMessage()
try {
await api.submit(data)
} catch (error) {
// Revert on failure
hideSuccessMessage()
setError('root', 'Submission failed')
}
}Form State Management
Unsaved Changes Warning
<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
const { formState } = useForm({ schema })
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (formState.value.isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
</script>Auto-Save
import { watchDebounced } from '@vueuse/core'
const formData = watch()
watchDebounced(
formData,
async (values) => {
if (formState.value.isDirty && formState.value.isValid) {
await saveDraft(values)
}
},
{ debounce: 2000 },
)Multi-Step Forms
Build wizard-style forms where each step has its own validation schema and data persists across all steps.
Per-Step Schema Definition
Define separate Zod schemas for each step, then combine them for the full form:
// schemas/checkout.ts
import { z } from 'zod'
// Step 1: Personal Info
export const personalInfoSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email address'),
})
// Step 2: Shipping Address
export const shippingSchema = z.object({
street: z.string().min(1, 'Street address is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(2, 'State is required'),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
})
// Step 3: Payment
export const paymentSchema = z.object({
cardNumber: z.string().regex(/^\d{16}$/, 'Card number must be 16 digits'),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'Format: MM/YY'),
cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
})
// Combined schema for full form validation on final submit
export const checkoutSchema = personalInfoSchema.merge(shippingSchema).merge(paymentSchema)
export type CheckoutFormData = z.infer<typeof checkoutSchema>Complete Multi-Step Form Component
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from '@vuehookform/core'
import { checkoutSchema, type CheckoutFormData } from '@/schemas/checkout'
// Step configuration with field mappings
const steps = [
{
id: 1,
name: 'Personal Info',
fields: ['firstName', 'lastName', 'email'] as const,
},
{
id: 2,
name: 'Shipping',
fields: ['street', 'city', 'state', 'zip'] as const,
},
{
id: 3,
name: 'Payment',
fields: ['cardNumber', 'expiry', 'cvv'] as const,
},
]
const currentStep = ref(1)
const totalSteps = steps.length
// Use the combined schema - data persists across all steps
const { register, handleSubmit, formState, trigger } = useForm({
schema: checkoutSchema,
mode: 'onSubmit',
reValidateMode: 'onChange', // Real-time feedback after step validation
defaultValues: {
firstName: '',
lastName: '',
email: '',
street: '',
city: '',
state: '',
zip: '',
cardNumber: '',
expiry: '',
cvv: '',
},
})
const currentStepConfig = computed(() => steps[currentStep.value - 1])
// Validate only the current step's fields
const validateCurrentStep = async (): Promise<boolean> => {
const stepFields = currentStepConfig.value.fields
const isValid = await trigger([...stepFields], {
markAsSubmitted: true, // Activates reValidateMode for these fields
})
return isValid
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep.value < totalSteps) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 1) {
currentStep.value--
}
}
// Data persists - all fields from all steps are available on submit
const onSubmit = async (data: CheckoutFormData) => {
console.log('Complete form data:', data)
// Submit to your API
}
</script>
<template>
<div class="multi-step-form">
<!-- Progress indicator -->
<div class="progress flex gap-2 mb-6">
<div
v-for="step in steps"
:key="step.id"
class="step-indicator px-3 py-1 rounded"
:class="{
'bg-blue-500 text-white': step.id === currentStep,
'bg-green-500 text-white': step.id < currentStep,
'bg-gray-200': step.id > currentStep,
}"
>
{{ step.name }}
</div>
</div>
<form @submit="handleSubmit(onSubmit)">
<!-- Step 1: Personal Info -->
<div v-show="currentStep === 1" class="step-content">
<h2 class="text-xl font-bold mb-4">Personal Information</h2>
<div class="field mb-4">
<label class="block mb-1">First Name</label>
<input v-bind="register('firstName')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.firstName" class="text-red-500 text-sm">
{{ formState.value.errors.firstName }}
</span>
</div>
<div class="field mb-4">
<label class="block mb-1">Last Name</label>
<input v-bind="register('lastName')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.lastName" class="text-red-500 text-sm">
{{ formState.value.errors.lastName }}
</span>
</div>
<div class="field mb-4">
<label class="block mb-1">Email</label>
<input v-bind="register('email')" type="email" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.email" class="text-red-500 text-sm">
{{ formState.value.errors.email }}
</span>
</div>
</div>
<!-- Step 2: Shipping -->
<div v-show="currentStep === 2" class="step-content">
<h2 class="text-xl font-bold mb-4">Shipping Address</h2>
<div class="field mb-4">
<label class="block mb-1">Street Address</label>
<input v-bind="register('street')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.street" class="text-red-500 text-sm">
{{ formState.value.errors.street }}
</span>
</div>
<div class="field mb-4">
<label class="block mb-1">City</label>
<input v-bind="register('city')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.city" class="text-red-500 text-sm">
{{ formState.value.errors.city }}
</span>
</div>
<div class="flex gap-4">
<div class="field mb-4 flex-1">
<label class="block mb-1">State</label>
<input v-bind="register('state')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.state" class="text-red-500 text-sm">
{{ formState.value.errors.state }}
</span>
</div>
<div class="field mb-4 flex-1">
<label class="block mb-1">ZIP Code</label>
<input v-bind="register('zip')" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.zip" class="text-red-500 text-sm">
{{ formState.value.errors.zip }}
</span>
</div>
</div>
</div>
<!-- Step 3: Payment -->
<div v-show="currentStep === 3" class="step-content">
<h2 class="text-xl font-bold mb-4">Payment Details</h2>
<div class="field mb-4">
<label class="block mb-1">Card Number</label>
<input
v-bind="register('cardNumber')"
placeholder="1234567890123456"
class="w-full p-2 border rounded"
/>
<span v-if="formState.value.errors.cardNumber" class="text-red-500 text-sm">
{{ formState.value.errors.cardNumber }}
</span>
</div>
<div class="flex gap-4">
<div class="field mb-4 flex-1">
<label class="block mb-1">Expiry</label>
<input
v-bind="register('expiry')"
placeholder="MM/YY"
class="w-full p-2 border rounded"
/>
<span v-if="formState.value.errors.expiry" class="text-red-500 text-sm">
{{ formState.value.errors.expiry }}
</span>
</div>
<div class="field mb-4 flex-1">
<label class="block mb-1">CVV</label>
<input v-bind="register('cvv')" type="password" class="w-full p-2 border rounded" />
<span v-if="formState.value.errors.cvv" class="text-red-500 text-sm">
{{ formState.value.errors.cvv }}
</span>
</div>
</div>
</div>
<!-- Navigation -->
<div class="actions flex gap-2 mt-6">
<button
type="button"
@click="prevStep"
:disabled="currentStep === 1"
class="px-4 py-2 border rounded disabled:opacity-50"
>
Back
</button>
<button
v-if="currentStep < totalSteps"
type="button"
@click="nextStep"
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Next
</button>
<button
v-else
type="submit"
:disabled="formState.value.isSubmitting"
class="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
>
{{ formState.value.isSubmitting ? 'Processing...' : 'Place Order' }}
</button>
</div>
</form>
</div>
</template>Key Concepts
Data Persistence: The form uses a single useForm instance with the combined schema. All field values persist in the form's internal state regardless of which step is visible. Using v-show (instead of v-if) keeps fields in the DOM even when hidden, preserving their registration.
Per-Step Validation: The trigger() method validates only the current step's fields. The markAsSubmitted: true option activates reValidateMode so subsequent changes to validated fields show immediate feedback.
Schema Composition: Zod's .merge() combines individual step schemas into a full form schema, ensuring:
- TypeScript types are accurate for the complete form
- Full validation works on final submission
- Each step schema can be reused independently
Alternative: Separate Forms Per Step
For complex wizards where steps are completely independent (different data models, save each step separately):
import { ref, reactive } from 'vue'
import { useForm } from '@vuehookform/core'
import { personalInfoSchema, shippingSchema, paymentSchema } from '@/schemas/checkout'
// Store completed step data outside of forms
const formData = reactive<Partial<CheckoutFormData>>({})
const currentStep = ref(1)
// Step 1 form
const step1Form = useForm({
schema: personalInfoSchema,
mode: 'onBlur',
})
const onStep1Complete = step1Form.handleSubmit((data) => {
Object.assign(formData, data)
currentStep.value = 2
})
// Step 2 form
const step2Form = useForm({
schema: shippingSchema,
mode: 'onBlur',
})
const onStep2Complete = step2Form.handleSubmit((data) => {
Object.assign(formData, data)
currentStep.value = 3
})
// Step 3 form
const step3Form = useForm({
schema: paymentSchema,
mode: 'onBlur',
})
// Final submission uses combined data from all steps
const onFinalSubmit = step3Form.handleSubmit(async (paymentData) => {
const completeData = { ...formData, ...paymentData }
await submitOrder(completeData as CheckoutFormData)
})This approach is better when:
- Each step saves data to the server independently
- Steps have completely different schemas that don't merge well
- You need different validation modes per step
Reusable Field Components
Field Wrapper
<!-- FormField.vue -->
<script setup>
import { useFormContext } from '@vuehookform/core'
const props = defineProps<{
name: string
label?: string
type?: string
required?: boolean
}>()
const { register, formState } = useFormContext()
const error = computed(() => formState.value.errors[props.name])
</script>
<template>
<div class="form-field" :class="{ 'has-error': error }">
<label v-if="label" :for="name">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<input :id="name" v-bind="register(name)" :type="type ?? 'text'" />
<p v-if="error" class="error" role="alert">{{ error }}</p>
</div>
</template>Testing
Unit Testing Forms
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import LoginForm from './LoginForm.vue'
describe('LoginForm', () => {
it('shows error for invalid email', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[name="email"]').setValue('invalid')
await wrapper.find('form').trigger('submit')
expect(wrapper.text()).toContain('Invalid email')
})
it('calls onSubmit with valid data', async () => {
const onSubmit = vi.fn()
const wrapper = mount(LoginForm, {
props: { onSubmit },
})
await wrapper.find('input[name="email"]').setValue('test@example.com')
await wrapper.find('input[name="password"]').setValue('password123')
await wrapper.find('form').trigger('submit')
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
})
})
})Accessibility
ARIA Attributes
<template>
<div class="field">
<label :for="name">{{ label }}</label>
<input
:id="name"
v-bind="register(name)"
:aria-invalid="!!error"
:aria-describedby="error ? `${name}-error` : undefined"
/>
<p v-if="error" :id="`${name}-error`" role="alert" class="error">
{{ error }}
</p>
</div>
</template>Focus Management
const onSubmit = async (data) => {
const firstError = Object.keys(formState.value.errors)[0]
if (firstError) {
const element = document.querySelector(`[name="${firstError}"]`)
element?.focus()
}
}Common Mistakes to Avoid
Don't Mix v-model with register
<!-- Wrong -->
<input v-model="email" v-bind="register('email')" />
<!-- Right: uncontrolled -->
<input v-bind="register('email')" />
<!-- Right: controlled -->
<input v-model="emailValue" v-bind="emailBindings" />Don't Use Array Bracket Notation
// Wrong
register('items[0].name')
// Right
register('items.0.name')
register(`items.${index}.name`)Don't Forget .value for Refs
<!-- Wrong -->
<span v-if="formState.errors.email"></span>Don't Use Index as Key in Field Arrays
<!-- Wrong -->
<div v-for="(field, index) in items.value" :key="index"></div>Summary
- Organize schemas in separate files
- Create reusable field components
- Handle errors consistently
- Implement proper server integration
- Add accessibility attributes
- Test your forms thoroughly
- Avoid common mistakes
