useForm
The main composable for managing form state, validation, and submission.
Import
import { useForm } from '@vuehookform/core'Usage
const form = useForm({
schema: z.object({
email: z.email(),
password: z.string().min(8),
}),
})Options
schema
Type: ZodSchema
Required: Yes
The Zod schema that defines your form structure and validation rules.
const schema = z.object({
email: z.email('Invalid email'),
age: z.number().min(18),
})
const form = useForm({ schema })defaultValues
Type: Partial<z.infer<typeof schema>>
Default: {}
Initial values for form fields.
const form = useForm({
schema,
defaultValues: {
email: 'user@example.com',
age: 25,
},
})mode
Type: 'onSubmit' | 'onBlur' | 'onChange' | 'onTouched'
Default: 'onSubmit'
When to trigger validation. See Validation Modes for detailed descriptions.
const form = useForm({
schema,
mode: 'onBlur',
})reValidateMode
Type: 'onSubmit' | 'onBlur' | 'onChange'
Default: 'onChange'
When to re-validate after the first submission. This allows different validation behavior before vs after the user submits.
const form = useForm({
schema,
mode: 'onSubmit',
reValidateMode: 'onChange', // After first submit, validate on every change
})disabled
Type: Ref<boolean> | boolean
Default: false
Disable the entire form. When true:
- All registered inputs receive
disabledattribute - Form submission is blocked
import { ref } from 'vue'
const isLoading = ref(false)
const form = useForm({
schema,
disabled: isLoading,
})shouldUseNativeValidation
Type: boolean
Default: false
Enable browser's native validation UI and HTML5 validation attributes.
const form = useForm({
schema,
shouldUseNativeValidation: true,
})shouldUnregister
Type: boolean
Default: false
Remove field data when a field is unmounted.
const form = useForm({
schema,
shouldUnregister: true,
})shouldFocusError
Type: boolean
Default: true
Automatically focus the first field with an error after validation fails.
const form = useForm({
schema,
shouldFocusError: false, // Disable auto-focus on errors
})criteriaMode
Type: 'firstError' | 'all'
Default: 'firstError'
How to collect validation errors. When 'all', errors include a types property with all validation failures.
const form = useForm({
schema,
criteriaMode: 'all', // Collect all validation errors per field
})delayError
Type: number
Default: undefined
Delay in milliseconds before displaying validation errors. Prevents error flash during typing.
const form = useForm({
schema,
mode: 'onChange',
delayError: 500, // Wait 500ms before showing errors
})validationDebounce
Type: number
Default: undefined
Debounce time in milliseconds for schema validation in onChange mode. Reduces validation calls during rapid typing, improving performance.
const form = useForm({
schema,
mode: 'onChange',
validationDebounce: 150, // Debounce validation by 150ms
})Comparison with delayError:
| Feature | validationDebounce | delayError |
|---|---|---|
| What it delays | Validation execution | Error display |
| Performance benefit | Reduces validation calls | None |
| UX benefit | Prevents input lag | Prevents error flash |
You can use both together for optimal UX:
const form = useForm({
schema,
mode: 'onChange',
validationDebounce: 150, // Reduce validation overhead
delayError: 300, // Smooth error display
})values
Type: MaybeRef<Partial<T>>
Default: undefined
External values to sync with the form. Changes don't mark fields as dirty.
import { computed } from 'vue'
const externalValues = computed(() => ({ search: route.query.q }))
const form = useForm({
schema,
values: externalValues,
})errors
Type: MaybeRef<Partial<FieldErrors<T>>>
Default: undefined
External errors to merge with validation errors (e.g., server-side errors).
import { ref } from 'vue'
const serverErrors = ref({})
const form = useForm({
schema,
errors: serverErrors,
})onDefaultValuesError
Type: (error: unknown) => void
Default: undefined
Callback when async default values fail to load.
const form = useForm({
schema,
defaultValues: async () => fetch('/api/data').then((r) => r.json()),
onDefaultValuesError: (error) => {
console.error('Failed to load:', error)
},
})Return Values
register
register(name: Path<T>, options?: RegisterOptions): RegisterReturnRegister an input field.
Parameters:
name- Field path (e.g.,'email','address.city','items.0.name')options.controlled- Enable controlled mode for v-model
Returns (Uncontrolled):
{
name: string
ref: (el: HTMLElement | null) => void
onInput: (e: Event) => void
onBlur: (e: Event) => void
disabled?: boolean // Present when form is disabled
}Returns (Controlled):
{
value: Ref<T>
name: string
onInput: (e: Event) => void
onBlur: (e: Event) => void
disabled?: boolean // Present when form is disabled
}Example:
<!-- Uncontrolled -->
<input v-bind="register('email')" />
<!-- Controlled -->
<script setup>
const { value, ...bindings } = register('email', { controlled: true })
</script>
<CustomInput v-model="value" v-bind="bindings" />handleSubmit
handleSubmit(
onValid: (data: T) => void | Promise<void>,
onInvalid?: (errors: FieldErrors<T>) => void
): (e: Event) => Promise<void>Create a submit handler that validates before calling your callback.
Parameters:
onValid- Called with validated data if validation passesonInvalid- Called with errors if validation fails
Example:
<form @submit="handleSubmit(onSubmit, onError)"></form>formState
formState: Ref<FormState<T>>Reactive form state object. See FormState.
fields
fields(name: ArrayPath<T>): FieldArrayReturn<T>Get a field array manager for dynamic lists. See FieldArray.
Example:
const items = fields('items')
items.append({ name: '' })setValue
setValue(name: Path<T>, value: any, options?: SetValueOptions): voidSet a field value programmatically.
Options:
shouldValidate- Trigger validation after setting (default: false)shouldDirty- Evaluate dirty state after setting (default: true). When true, the field's dirty state is updated based on whether the new value differs from the default. When false, dirty state is not modified.
Example:
setValue('email', 'new@example.com')
setValue('age', 25, { shouldValidate: true })getValues
getValues(): T
getValues(names: Path<T>[]): Partial<T>Get all form values or specific fields.
const allValues = getValues()
const { email, name } = getValues(['email', 'name'])reset
reset(values?: Partial<T>): voidReset form to default values or provided values.
reset() // Reset to defaultValues
reset({ email: 'new@example.com' }) // Reset with new valuestrigger
trigger(name?: Path<T> | Path<T>[], options?: TriggerOptions): Promise<boolean>Manually trigger validation.
Options:
markAsSubmitted- Increment submitCount to activate reValidateMode behavior (default: false)
await trigger() // Validate all fields
await trigger('email') // Validate specific field
await trigger(['email', 'password']) // Validate multiple fields
// Activate reValidateMode (useful for multi-step form validation)
await trigger('email', { markAsSubmitted: true })Multi-Step Forms
Use markAsSubmitted: true when validating individual steps to ensure subsequent changes trigger re-validation according to your reValidateMode setting.
watch
watch(): ComputedRef<T>
watch(name: Path<T>): ComputedRef<PathValue<T, Path>>
watch(names: Path<T>[]): ComputedRef<Partial<T>>Watch field values reactively.
const allValues = watch()
const email = watch('email')
// Multiple fields returns an object, not a tuple
const credentials = watch(['email', 'password'])
// Access: credentials.value.email, credentials.value.passwordsetError
setError(name: Path<T> | 'root' | `root.${string}`, error: ErrorOption): voidSet an error on a specific field.
ErrorOption:
{
type?: string // Error type (e.g., 'server', 'validation')
message: string // Error message to display
persistent?: boolean // If true, error survives subsequent validations
}Examples:
// Simple error message
setError('email', { message: 'This email is already taken' })
// With custom type
setError('email', { type: 'server', message: 'Email already exists' })
// Persistent error (survives validation, cleared only by clearErrors)
setError('email', { message: 'Server validation failed', persistent: true })
// Root-level error
setError('root', { message: 'Form submission failed' })Persistent Errors
Use persistent: true for server-side validation errors that should remain visible even when the user modifies the field. The error will only be cleared when you explicitly call clearErrors().
clearErrors
clearErrors(name?: Path<T> | Path<T>[] | 'root'): voidClear errors from fields.
clearErrors() // Clear all errors
clearErrors('email') // Clear specific field
clearErrors(['email', 'password']) // Clear multiple fields
clearErrors('root') // Clear root-level errorunregister
unregister(name: Path<T>, options?: UnregisterOptions): voidRemove a field from form tracking.
Options:
keepValue- Don't clear value (default: false)keepError- Keep validation error (default: false)keepDirty- Keep dirty state (default: false)keepTouched- Keep touched state (default: false)keepDefaultValue- Keep stored default (default: false)keepIsValid- Don't re-evaluate form validity (default: false)
unregister('optionalField')
unregister('field', { keepValue: true })resetField
resetField(name: Path<T>, options?: ResetFieldOptions): voidReset a single field to its default value.
Options:
keepError- Keep error (default: false)keepDirty- Keep dirty state (default: false)keepTouched- Keep touched state (default: false)defaultValue- New default value for this field
resetField('email')
resetField('email', { defaultValue: 'new@example.com' })getFieldState
getFieldState(name: Path<T>): FieldStateGet the state of a specific field.
Returns:
{
isDirty: boolean
isTouched: boolean
invalid: boolean
error?: string | FieldError
}const emailState = getFieldState('email')
if (emailState.invalid) {
console.log(emailState.error)
}setErrors
setErrors(errors: Record<Path<T>, string | ErrorOption>, options?: SetErrorsOptions): voidSet multiple errors at once.
Options:
shouldReplace- Replace all errors instead of merging (default: false)
setErrors({
email: 'Email already exists',
username: 'Username is taken',
})
setErrors({ email: 'Error' }, { shouldReplace: true })hasErrors
hasErrors(name?: Path<T> | 'root'): booleanCheck if the form or a specific field has errors.
if (hasErrors()) {
console.log('Form has errors')
}
if (hasErrors('email')) {
console.log('Email has an error')
}getErrors
getErrors(): FieldErrors<T>
getErrors(name: Path<T>): FieldErrorValue | undefinedGet all errors or a specific field error.
const allErrors = getErrors()
const emailError = getErrors('email')setFocus
setFocus(name: Path<T>, options?: SetFocusOptions): voidProgrammatically focus a field.
Options:
shouldSelect- Select text in the input (default: false)
setFocus('email')
setFocus('email', { shouldSelect: true })control
control: Control<T>The form control object. Pass to child components for useController, useWatch, or useFormState.
// Parent
const { control } = useForm({ schema })
// Child
<ChildComponent :control="control" />Dynamic Path Overloads
Many form methods support loose overloads that accept string paths without requiring type casts. This is useful when working with dynamic paths (e.g., in field arrays or reusable components).
Methods with Loose Overloads
The following methods have both strict and loose overloads:
| Method | Strict Signature | Loose Signature |
|---|---|---|
register | register(name: Path<T>) | register(name: string) |
setValue | setValue(name: Path<T>, value: PathValue) | setValue(name: string, value: unknown) |
getValues | getValues(name: Path<T>) | getValues(name: string) |
watch | watch(name: Path<T>) | watch(name: string) |
getFieldState | getFieldState(name: Path<T>) | getFieldState(name: string) |
trigger | trigger(name: Path<T>) | trigger(name: string) |
clearErrors | clearErrors(name: Path<T>) | clearErrors(name: string) |
setError | setError(name: Path<T>) | setError(name: string) |
setFocus | setFocus(name: Path<T>) | setFocus(name: string) |
resetField | resetField(name: Path<T>) | resetField(name: string) |
unregister | unregister(name: Path<T>) | unregister(name: string) |
hasErrors | hasErrors(name: Path<T>) | hasErrors(name: string) |
validate | validate(name: Path<T>) | validate(name: string) |
Why Loose Overloads?
TypeScript's template literal types sometimes can't infer dynamic paths:
// Without loose overloads, this would require a cast
const index = 0
register(`items.${index}.name`) // TypeScript can't verify this at compile time
// With loose overloads, it works without casts
register(`items.${index}.name`) // ✅ Accepted by loose overloadType Safety Trade-offs
| Approach | Type Safety | Flexibility |
|---|---|---|
Static paths (register('email')) | Full autocomplete, compile-time errors | Static only |
Dynamic paths (register(\items.${i}.name`)`) | Runtime only | Dynamic |
Example: Field Arrays with Dynamic Paths
const items = fields('items')
// Option 1: Scoped methods (recommended) - full type safety
items.value.forEach((field) => {
field.register('name') // ✅ Full type safety
})
// Option 2: Loose overloads - no cast needed
items.value.forEach((field) => {
register(`items.${field.index}.name`) // ✅ Works without cast
})Best Practice
For field arrays, prefer scoped methods (field.register('name')) which provide full type safety. Use loose overloads when you need to construct paths dynamically outside of field array items.
