Examples
Interactive examples demonstrating Vue Hook Form capabilities. Try out the forms below to see how Vue Hook Form works.
Interactive Demos
Basic Form
A simple form with email, name, and password validation. Uses onBlur validation mode.
View Code
<script setup lang="ts">
import { ref } from 'vue'
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Please enter a valid email'),
name: z.string().min(2, 'Name must be at least 2 characters'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
type FormValues = z.infer<typeof schema>
const { register, handleSubmit, formState } = useForm({
schema,
mode: 'onBlur',
defaultValues: { email: '', name: '', password: '' },
})
const submittedData = ref<FormValues | null>(null)
const onSubmit = (data: FormValues) => {
submittedData.value = data
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div class="field">
<label for="email">Email</label>
<input
id="email"
type="email"
v-bind="register('email')"
:class="{ 'has-error': formState.errors.email }"
/>
<span v-if="formState.errors.email" class="error">
{{ formState.errors.email }}
</span>
</div>
<!-- Similar for name and password fields -->
<button type="submit">Submit</button>
</form>
</template>Controlled Inputs
Use controlled mode for select, number inputs, and textareas. Controlled mode is required when using v-model.
View Code
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
country: z.string().min(1, 'Please select a country'),
age: z.coerce.number().min(18, 'Must be at least 18'),
bio: z.string().min(10, 'Bio must be at least 10 characters'),
})
const { register, handleSubmit, formState } = useForm({
schema,
mode: 'onBlur',
})
// Controlled inputs - destructure value for v-model
const { value: countryValue, ...countryBindings } = register('country', { controlled: true })
const { value: ageValue, ...ageBindings } = register('age', { controlled: true })
const { value: bioValue, ...bioBindings } = register('bio', { controlled: true })
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<select v-model="countryValue" v-bind="countryBindings">
<option value="">Select a country</option>
<option value="US">United States</option>
</select>
<input type="number" v-model="ageValue" v-bind="ageBindings" />
<textarea v-model="bioValue" v-bind="bioBindings" />
</form>
</template>Validation Modes
Switch between different validation modes to see how they affect when validation runs.
View Code
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { useForm, type ValidationMode } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Invalid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
})
const selectedMode = ref<ValidationMode>('onSubmit')
const formKey = ref(0)
const createForm = (mode: ValidationMode) => {
return useForm({
schema,
mode,
defaultValues: { email: '', username: '' },
})
}
const form = shallowRef(createForm(selectedMode.value))
const onModeChange = () => {
form.value = createForm(selectedMode.value)
formKey.value++
}
</script>
<template>
<select v-model="selectedMode" @change="onModeChange">
<option value="onSubmit">onSubmit</option>
<option value="onBlur">onBlur</option>
<option value="onChange">onChange</option>
<option value="onTouched">onTouched</option>
</select>
<form :key="formKey" @submit.prevent="form.handleSubmit(onSubmit)($event)">
<input v-bind="form.register('email')" />
<input v-bind="form.register('username')" />
</form>
</template>Field Arrays
Dynamic arrays with add, remove, and validation support. Always use field.key for :key binding.
View Code
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2),
addresses: z
.array(
z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
}),
)
.min(1),
})
const { register, handleSubmit, fields, getErrors } = useForm({
schema,
defaultValues: {
name: '',
addresses: [{ street: '', city: '' }],
},
})
// Call fields() in setup, not in template
const addressFields = fields('addresses', {
rules: { minLength: { value: 1, message: 'At least one address required' } },
})
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<input v-bind="register('name')" />
<!-- Always use field.key for :key -->
<div v-for="field in addressFields.value" :key="field.key">
<h4>Address {{ field.index + 1 }}</h4>
<input v-bind="register(`addresses.${field.index}.street`)" />
<input v-bind="register(`addresses.${field.index}.city`)" />
<button type="button" @click="addressFields.remove(field.index)">Remove</button>
</div>
<button type="button" @click="addressFields.append({ street: '', city: '' })">
Add Address
</button>
</form>
</template>useController
For full control over field state, use useController. It provides reactive fieldState with error, dirty, and touched states.
View Code
<script setup lang="ts">
import { useForm, useController } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
})
const form = useForm({
schema,
mode: 'onBlur',
})
// useController provides reactive fieldState
const firstNameController = useController({
name: 'firstName',
control: form,
})
</script>
<template>
<form @submit="form.handleSubmit(onSubmit)">
<input
:value="firstNameController.field.value.value"
@input="firstNameController.field.onChange(($event.target as HTMLInputElement).value)"
@blur="firstNameController.field.onBlur"
/>
<!-- fieldState is reactive - updates automatically -->
<span v-if="firstNameController.fieldState.value.error">
{{ firstNameController.fieldState.value.error }}
</span>
<span>isDirty: {{ firstNameController.fieldState.value.isDirty }}</span>
<span>isTouched: {{ firstNameController.fieldState.value.isTouched }}</span>
</form>
</template>Form Context
Share form across deeply nested components without prop drilling using provideForm and useFormContext.
View Code
<!-- Parent Form -->
<script setup lang="ts">
import { useForm, provideForm } from '@vuehookform/core'
import { z } from 'zod'
import ChildField from './ChildField.vue'
const schema = z.object({
parentField: z.string().min(2),
childField: z.string().min(2),
})
const form = useForm({ schema })
// Make form available to ALL descendants
provideForm(form)
</script>
<template>
<form @submit="form.handleSubmit(onSubmit)">
<input v-bind="form.register('parentField')" />
<!-- Child component accesses form via context -->
<ChildField />
</form>
</template><!-- ChildField.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useFormContext } from '@vuehookform/core'
const { register, formState } = useFormContext()
// Use computed for reactive error access
const error = computed(() => formState.value.errors.childField)
</script>
<template>
<input v-bind="register('childField')" />
<span v-if="error">{{ error }}</span>
</template>Code Examples
Simple Login Form
A minimal login form with email and password validation.
<script setup>
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
})
const { register, handleSubmit, formState } = useForm({
schema,
mode: 'onBlur',
})
const onSubmit = (data) => console.log(data)
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<input v-bind="register('email')" type="email" placeholder="Email" />
<input v-bind="register('password')" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
</template>Registration Form
User registration with password confirmation.
<script setup>
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z
.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
})
const { register, handleSubmit, formState } = useForm({ schema })
</script>Dynamic Forms
Shopping Cart
Dynamic item list with quantity controls.
<script setup>
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
items: z
.array(
z.object({
product: z.string(),
quantity: z.number().min(1),
}),
)
.min(1),
})
const { register, handleSubmit, fields } = useForm({
schema,
defaultValues: { items: [{ product: '', quantity: 1 }] },
})
const items = fields('items')
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<div v-for="field in items.value" :key="field.key">
<input v-bind="register(`items.${field.index}.product`)" />
<input v-bind="register(`items.${field.index}.quantity`)" type="number" />
<button type="button" @click="items.remove(field.index)">Remove</button>
</div>
<button type="button" @click="items.append({ product: '', quantity: 1 })">Add Item</button>
<button type="submit">Checkout</button>
</form>
</template>Survey Builder
Create dynamic surveys with multiple question types.
<script setup>
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const questionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
question: z.string(),
answer: z.string(),
}),
z.object({
type: z.literal('rating'),
question: z.string(),
rating: z.number().min(1).max(5),
}),
])
const schema = z.object({
title: z.string(),
questions: z.array(questionSchema),
})
</script>Advanced Patterns
Multi-Step Wizard
Form split into multiple steps with validation per step.
<script setup>
import { ref } from 'vue'
import { useForm } from '@vuehookform/core'
const step = ref(1)
const { register, handleSubmit, trigger } = useForm({ schema })
const nextStep = async () => {
const valid = await trigger(stepFields[step.value])
if (valid) step.value++
}
</script>Nested Form Context
Share form across deeply nested components.
<script setup>
import { useForm, provideForm } from '@vuehookform/core'
const form = useForm({ schema })
provideForm(form) // Make form available to all descendants
</script>
<template>
<form @submit="form.handleSubmit(onSubmit)">
<PersonalInfoSection />
<AddressSection />
<PreferencesSection />
<SubmitButton />
</form>
</template>Reactive Error Display Patterns
Important
getFieldState() returns snapshots, not reactive refs. Using it incorrectly causes error messages to persist even after fixing validation issues.
❌ Wrong: Non-Reactive Error Display
This is a common mistake that causes errors to persist:
<script setup lang="ts">
import { useFormContext } from '@vuehookform/core'
const props = defineProps<{ name: string }>()
// ❌ WRONG: Snapshot never updates!
const fieldState = useFormContext().getFieldState(props.name)
</script>
<template>
<div>
<input v-bind="register(name)" />
<!-- This error will NEVER clear, even after fixing the input -->
<span v-if="fieldState.error" class="error">{{ fieldState.error }}</span>
</div>
</template>✅ Pattern 1: Pass Errors as Props (Simple Components)
Best for simple wrapper components where the parent manages the form:
<!-- Parent Form -->
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
import CustomInput from './CustomInput.vue'
const schema = z.object({
email: z.string().email('Invalid email address'),
username: z.string().min(3, 'At least 3 characters'),
})
const { register, handleSubmit, formState } = useForm({
schema,
mode: 'onBlur',
})
const { value: emailValue, ...emailBindings } = register('email', { controlled: true })
const { value: usernameValue, ...usernameBindings } = register('username', { controlled: true })
const onSubmit = (data) => {
console.log('Valid data:', data)
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)" class="form">
<CustomInput
v-model="emailValue"
v-bind="emailBindings"
label="Email"
type="email"
:error="formState.value.errors.email"
/>
<CustomInput
v-model="usernameValue"
v-bind="usernameBindings"
label="Username"
:error="formState.value.errors.username"
/>
<button type="submit" :disabled="formState.value.isSubmitting">Submit</button>
</form>
</template><!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{
modelValue: string
label?: string
type?: string
error?: string
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<div class="input-wrapper">
<label v-if="label" class="label">{{ label }}</label>
<input
:value="modelValue"
:type="type ?? 'text'"
:class="{ 'input-error': error }"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<!-- Error is passed as prop - fully reactive ✅ -->
<span v-if="error" class="error-message">{{ error }}</span>
</div>
</template>
<style scoped>
.input-wrapper {
margin-bottom: 1rem;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.input-error {
border-color: #ef4444;
}
.error-message {
display: block;
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>Pros:
- Simple and explicit
- Parent controls error display logic
- Easy to understand data flow
Cons:
- Requires passing errors as props
- Verbose when used with many fields
✅ Pattern 2: useController (Recommended for Reusable Components)
Best for reusable field components and third-party UI libraries:
<!-- FormField.vue - Fully encapsulated reusable component -->
<script setup lang="ts">
import { useController, type Control } from '@vuehookform/core'
const props = defineProps<{
name: string
control: Control<any>
label?: string
type?: string
placeholder?: string
}>()
// useController provides reactive fieldState ✅
const { field, fieldState } = useController({
name: props.name,
control: props.control,
})
</script>
<template>
<div class="form-field">
<label v-if="label" :for="name" class="label">{{ label }}</label>
<input
:id="name"
:name="field.name"
:type="type ?? 'text'"
:value="field.value"
:placeholder="placeholder"
:class="{ 'has-error': fieldState.error }"
@input="field.onChange(($event.target as HTMLInputElement).value)"
@blur="field.onBlur"
/>
<!-- fieldState is a ComputedRef - updates automatically! ✅ -->
<p v-if="fieldState.error" class="error-message" role="alert">
{{ fieldState.error }}
</p>
<!-- You can also access other reactive state -->
<p v-if="fieldState.isDirty" class="hint">Modified</p>
</div>
</template>
<style scoped>
.form-field {
margin-bottom: 1rem;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.has-error {
border-color: #ef4444;
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.hint {
color: #6b7280;
font-size: 0.75rem;
margin-top: 0.25rem;
}
</style>Usage:
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
import FormField from './FormField.vue'
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
age: z.coerce.number().min(18, 'Must be 18 or older'),
})
const { control, handleSubmit } = useForm({
schema,
mode: 'onBlur',
})
const onSubmit = (data) => {
console.log('Form data:', data)
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<!-- Clean, no error prop drilling needed -->
<FormField name="email" :control="control" label="Email" type="email" />
<FormField name="password" :control="control" label="Password" type="password" />
<FormField name="age" :control="control" label="Age" type="number" />
<button type="submit">Submit</button>
</form>
</template>Pros:
- Component owns its error display (encapsulated)
- Fully reactive via
fieldStateComputedRef - Perfect for building component libraries
- Works great with third-party UI components
Cons:
- Must pass
controlprop - Slightly more boilerplate than Pattern 1
✅ Pattern 3: Form Context (Deeply Nested Components)
Best for deeply nested component trees to avoid prop drilling:
<!-- FormWrapper.vue -->
<script setup lang="ts">
import { useForm, provideForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
firstName: z.string().min(2, 'At least 2 characters'),
lastName: z.string().min(2, 'At least 2 characters'),
email: z.string().email('Invalid email'),
phone: z.string().min(10, 'Invalid phone number'),
})
const form = useForm({
schema,
mode: 'onBlur',
})
// Make form available to ALL descendants ✅
provideForm(form)
const onSubmit = (data) => {
console.log('Form submitted:', data)
}
</script>
<template>
<form @submit="form.handleSubmit(onSubmit)" class="form-wrapper">
<h2>Contact Information</h2>
<PersonalInfoSection />
<ContactSection />
<button type="submit" :disabled="form.formState.value.isSubmitting">Submit</button>
</form>
</template><!-- PersonalInfoSection.vue (nested component) -->
<script setup lang="ts">
import { useFormContext } from '@vuehookform/core'
import FormInput from './FormInput.vue'
// Access form from any depth in the component tree ✅
const { register, formState } = useFormContext()
</script>
<template>
<section>
<h3>Personal Info</h3>
<FormInput name="firstName" label="First Name" />
<FormInput name="lastName" label="Last Name" />
</section>
</template><!-- FormInput.vue (deeply nested) -->
<script setup lang="ts">
import { computed } from 'vue'
import { useFormContext } from '@vuehookform/core'
const props = defineProps<{
name: string
label?: string
type?: string
}>()
const { register, formState } = useFormContext()
// Use computed to reactively access errors ✅
const error = computed(() => formState.value.errors[props.name])
const isTouched = computed(() => formState.value.touchedFields[props.name])
</script>
<template>
<div class="form-input">
<label v-if="label" :for="name">{{ label }}</label>
<input
:id="name"
v-bind="register(name)"
:type="type ?? 'text'"
:class="{ 'has-error': error }"
/>
<!-- Error updates reactively via computed ✅ -->
<span v-if="error" class="error">{{ error }}</span>
<span v-if="isTouched && !error" class="success">✓</span>
</div>
</template>
<style scoped>
.form-input {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.25rem;
}
input {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.has-error {
border-color: #ef4444;
}
.error {
color: #ef4444;
font-size: 0.875rem;
}
.success {
color: #10b981;
font-size: 0.875rem;
}
</style>Pros:
- No prop drilling through component hierarchy
- Clean component APIs
- Perfect for large, deeply nested forms
Cons:
- Implicit dependency on form context
- Harder to trace data flow in large apps
Summary: When to Use Each Pattern
| Pattern | Use Case | Reactivity | Complexity |
|---|---|---|---|
| Pattern 1: Props | Simple wrappers, few fields | ✅ Reactive | Low |
| Pattern 2: useController | Reusable components, UI libraries | ✅ Reactive (built-in) | Medium |
| Pattern 3: Form Context | Deeply nested forms | ✅ Reactive (computed) | Medium |
Key Takeaway: All three patterns are reactive because they use:
- Pattern 1:
formState.value.errors(computed property) - Pattern 2:
useController(providesfieldStateComputedRef) - Pattern 3:
formState.value.errorsviacomputed()or direct access
Never store getFieldState() result in a variable - it returns snapshots, not reactive refs!
