useController
A composable for building controlled input components that integrate with Vue Hook Form.
Import
import { useController } from '@vuehookform/core'Usage
const { field, fieldState } = useController({
name: 'email',
control,
})When to Use
Use useController when:
- Building reusable form input components
- Integrating with third-party UI libraries
- Need fine-grained control over input behavior
For simple cases, use register with controlled: true instead.
Why useController?
Native HTML elements emit standard events (@input, @blur) with predictable structure (Event.target.value). Third-party components often use different event patterns like @update:modelValue with direct values. useController provides explicit onChange(value) and onBlur() methods that work regardless of how the component emits changes.
Options
name
Type: Path<T>
Required: Yes
The field path to control.
const controller = useController({
name: 'user.email',
control,
})control
Type: Control<T>
Required: Yes
The control object from useForm.
const { control } = useForm({ schema })
const controller = useController({
name: 'email',
control,
})defaultValue
Type: any
Default: Value from form's defaultValues
Override the default value for this field.
const controller = useController({
name: 'country',
control,
defaultValue: 'US',
})Return Values
field
Object containing field props and methods:
{
value: Ref<T> // Current field value
name: string // Field name
onChange: (value: T) => void // Update value
onBlur: () => void // Mark as touched
}fieldState
Reactive field state:
{
error: string | undefined // Validation error
isTouched: boolean // Has been blurred
isDirty: boolean // Value differs from default
}Validation Mode Behavior
useController respects the form's validation mode settings:
| Mode | onChange behavior | onBlur behavior |
|---|---|---|
onSubmit | No validation | No validation |
onChange | Validates | No validation |
onBlur | No validation | Validates |
onTouched | Validates (after touched) | Validates (marks touched) |
After form submission (submitCount > 0), the reValidateMode takes effect:
const { control } = useForm({
schema,
mode: 'onSubmit',
reValidateMode: 'onChange', // After submit, validate on every change
})
const { field } = useController({ name: 'email', control })
// Before submit: field.onChange() does NOT validate
// After submit: field.onChange() DOES validateTIP
This behavior is consistent with register() and field array operations, ensuring uniform validation across your entire form.
Example: Custom Input Component
<!-- CustomInput.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { Control } from '@vuehookform/core'
const props = defineProps<{
name: string
control: Control<any>
label?: string
placeholder?: string
}>()
const { field, fieldState } = useController({
name: props.name,
control: props.control,
})
</script>
<template>
<div class="form-field">
<label v-if="label" :for="name">{{ label }}</label>
<input
:id="name"
:name="field.name"
:value="field.value"
:placeholder="placeholder"
:class="{ error: fieldState.error }"
@input="field.onChange(($event.target as HTMLInputElement).value)"
@blur="field.onBlur"
/>
<span v-if="fieldState.error" class="error-message">
{{ fieldState.error }}
</span>
</div>
</template>Usage:
<script setup>
import { useForm } from '@vuehookform/core'
import CustomInput from './CustomInput.vue'
const { control, handleSubmit } = useForm({ schema })
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<CustomInput
name="email"
:control="control"
label="Email Address"
placeholder="Enter your email"
/>
<CustomInput name="password" :control="control" label="Password" />
</form>
</template>Example: Select Component
<!-- CustomSelect.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { Control } from '@vuehookform/core'
const props = defineProps<{
name: string
control: Control<any>
options: { value: string; label: string }[]
label?: string
}>()
const { field, fieldState } = useController({
name: props.name,
control: props.control,
})
</script>
<template>
<div class="form-field">
<label v-if="label" :for="name">{{ label }}</label>
<select
:id="name"
:name="field.name"
:value="field.value"
@change="field.onChange(($event.target as HTMLSelectElement).value)"
@blur="field.onBlur"
>
<option value="" disabled>Select an option</option>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<span v-if="fieldState.error" class="error-message">
{{ fieldState.error }}
</span>
</div>
</template>Example: Checkbox Component
<!-- CustomCheckbox.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { Control } from '@vuehookform/core'
const props = defineProps<{
name: string
control: Control<any>
label: string
}>()
const { field, fieldState } = useController({
name: props.name,
control: props.control,
})
</script>
<template>
<div class="checkbox-field">
<label>
<input
type="checkbox"
:name="field.name"
:checked="field.value"
@change="field.onChange(($event.target as HTMLInputElement).checked)"
@blur="field.onBlur"
/>
{{ label }}
</label>
<span v-if="fieldState.error" class="error-message">
{{ fieldState.error }}
</span>
</div>
</template>Loose Typing for Reusable Components
When building reusable form field components, use LooseControl and LooseControllerOptions to avoid type casting:
<!-- FormInput.vue - Reusable component -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'
const props = defineProps<{
name: string
control: LooseControl // Works with any form
label?: string
placeholder?: string
}>()
// No cast needed - useController accepts loose options
const { field, fieldState } = useController({
name: props.name,
control: props.control,
})
</script>
<template>
<div class="form-field">
<label v-if="label" :for="name">{{ label }}</label>
<input
:id="name"
:name="field.name"
:value="field.value"
:placeholder="placeholder"
@input="field.onChange(($event.target as HTMLInputElement).value)"
@blur="field.onBlur"
/>
<span v-if="fieldState.error" class="error">
{{ fieldState.error }}
</span>
</div>
</template>Usage with any form:
<script setup>
import { useForm } from '@vuehookform/core'
import FormInput from './FormInput.vue'
import { z } from 'zod'
const schema = z.object({
email: z.email(),
name: z.string().min(2),
})
const { control, handleSubmit } = useForm({ schema })
</script>
<template>
<form @submit="handleSubmit(onSubmit)">
<!-- FormInput works with any schema -->
<FormInput name="email" :control="control" label="Email" />
<FormInput name="name" :control="control" label="Name" />
<button type="submit">Submit</button>
</form>
</template>Why LooseControl?
Previously you might have used Control<any> which loses all type information. LooseControl is semantically clearer and provides better IDE support for the form methods.
Comparison with register
| Feature | register | useController |
|---|---|---|
| Simple inputs | Great | Overkill |
| Custom components | With controlled: true | Ideal |
| Field state access | Via formState | Direct access |
| Third-party integration | Possible | Easier |
| Bundle size impact | Minimal | Slightly larger |
