Skip to content

useController

A composable for building controlled input components that integrate with Vue Hook Form.

Import

typescript
import { useController } from '@vuehookform/core'

Usage

typescript
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.

typescript
const controller = useController({
  name: 'user.email',
  control,
})

control

Type: Control<T>
Required: Yes

The control object from useForm.

typescript
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.

typescript
const controller = useController({
  name: 'country',
  control,
  defaultValue: 'US',
})

Return Values

field

Object containing field props and methods:

typescript
{
  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:

typescript
{
  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:

ModeonChange behavioronBlur behavior
onSubmitNo validationNo validation
onChangeValidatesNo validation
onBlurNo validationValidates
onTouchedValidates (after touched)Validates (marks touched)

After form submission (submitCount > 0), the reValidateMode takes effect:

typescript
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 validate

TIP

This behavior is consistent with register() and field array operations, ensuring uniform validation across your entire form.

Example: Custom Input Component

vue
<!-- 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:

vue
<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

vue
<!-- 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

vue
<!-- 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:

vue
<!-- 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:

vue
<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

FeatureregisteruseController
Simple inputsGreatOverkill
Custom componentsWith controlled: trueIdeal
Field state accessVia formStateDirect access
Third-party integrationPossibleEasier
Bundle size impactMinimalSlightly larger

Released under the MIT License.