One Composable, Full Control
No scattered field composables. Just useForm() with everything you need - registration, validation, arrays, state.
One composable. Schema-driven. Perfect types.
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
})
const { register, handleSubmit, formState } = useForm({
schema,
mode: 'onBlur',
})
// Data is fully typed as { email: string; password: string }
const onSubmit = (data: z.infer<typeof schema>) => {
console.log(data.email, data.password)
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<input v-bind="register('email')" type="email" />
<span v-if="formState.value.errors.email">
{{ formState.value.errors.email }}
</span>
<input v-bind="register('password')" type="password" />
<span v-if="formState.value.errors.password">
{{ formState.value.errors.password }}
</span>
<button type="submit" :disabled="formState.value.isSubmitting">Submit</button>
</form>
</template>Your IDE knows every field path. Invalid paths cause TypeScript errors at compile time.
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
user: z.object({
name: z.string().min(2, 'Name too short'),
email: z.string().email('Invalid email'),
}),
address: z.object({
street: z.string().min(1, 'Required'),
city: z.string().min(1, 'Required'),
}),
})
const { register, handleSubmit } = useForm({ schema })
// Full autocomplete: data.user.name, data.address.city
const onSubmit = (data: z.infer<typeof schema>) => {
console.log(`${data.user.name} from ${data.address.city}`)
}
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<!-- Paths are type-checked: 'user.name' works, 'user.invalid' errors -->
<input v-bind="register('user.name')" placeholder="Name" />
<input v-bind="register('user.email')" type="email" placeholder="Email" />
<input v-bind="register('address.street')" placeholder="Street" />
<input v-bind="register('address.city')" placeholder="City" />
<button type="submit">Submit</button>
</form>
</template>Built-in array management with stable keys. No external libraries needed.
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'
const schema = z.object({
teamName: z.string().min(1, 'Required'),
members: z
.array(
z.object({
name: z.string().min(1, 'Required'),
role: z.enum(['developer', 'designer', 'manager']),
}),
)
.min(1, 'Add at least one member'),
})
const { register, handleSubmit, fields } = useForm({
schema,
defaultValues: { teamName: '', members: [{ name: '', role: 'developer' }] },
})
// Get field array manager - call once in setup
const members = fields('members')
</script>
<template>
<form @submit="handleSubmit((data) => console.log(data))">
<input v-bind="register('teamName')" placeholder="Team name" />
<!-- Always use field.key for v-for, never index -->
<div v-for="field in members.value" :key="field.key">
<!-- Dot notation for array paths: members.0.name, not members[0].name -->
<input v-bind="register(`members.${field.index}.name`)" placeholder="Name" />
<select v-bind="register(`members.${field.index}.role`)">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
</select>
<button type="button" @click="members.remove(field.index)">Remove</button>
</div>
<button type="button" @click="members.append({ name: '', role: 'developer' })">
Add Member
</button>
<button type="submit">Create Team</button>
</form>
</template>Building forms in Vue often means choosing between:
Vue Hook Form takes a different approach:
useForm() composable manages everything. Cross-field validation? Simple. Form-wide state? One object.| Feature | Vue Hook Form | VeeValidate | FormKit |
|---|---|---|---|
| Bundle | ~10kb | ~15kb | ~50kb |
| API Style | Form-level | Field-level | Component |
| Zod Support | Native | Adapter | Limited |
| TypeScript | Perfect | Good | Good |
Four validation modes to match your UX needs:
useForm({
schema,
mode: 'onSubmit', // Only on submit (default) - cleanest UX
mode: 'onBlur', // When field loses focus - recommended balance
mode: 'onChange', // Every keystroke - real-time feedback
mode: 'onTouched', // After first touch, then on change
})Pro tip: Use reValidateMode: 'onChange' for instant feedback after first validation.
| Wrong | Right |
|---|---|
items[0].name | items.0.name |
:key="index" | :key="field.key" |
formState.errors | formState.value.errors |
v-model + register() | Either one, not both |