Skip to content

Custom Components

Build reusable form field components that integrate with Vue Hook Form.

Choosing the Right Approach

Your ComponentRecommended Approach
Native HTML (<input>, <select>, <textarea>)register() (uncontrolled)
Vue component exposing input via $elregister() (uncontrolled)
Custom component with standard v-modelregister(..., { controlled: true })
Third-party with custom events (Calendar, etc.)useController
Building reusable form componentsuseController
Deeply nested componentsForm Context

Choosing Between Approaches for Third-Party Libraries

Many simple input components (like PrimeVue's InputText or Vuetify's v-text-field) now work with uncontrolled register() because they expose their input element via $el. For complex components with custom events (date pickers, rich selects, autocompletes), useController provides explicit onChange(value) and onBlur() methods that work regardless of the component's event model.

Critical: Error Messages in Custom Components

Problem: Using getFieldState() in custom components causes error messages to persist incorrectly after fixing validation issues.

Why? getFieldState() returns a plain object snapshot, NOT reactive refs. If you call it once in setup, that snapshot never updates.

vue
<!-- ❌ WRONG - Error persists even after valid input -->
<script setup>
const { getFieldState } = useFormContext()
const fieldState = getFieldState(props.name) // Snapshot!
</script>
<template>
  <span v-if="fieldState.error">{{ fieldState.error }}</span>
  <!-- This error will NOT clear when user fixes the input -->
</template>

Solutions:

  1. Use useController (RECOMMENDED) - Provides reactive fieldState:

    vue
    <script setup>
    const { field, fieldState } = useController({ name: props.name, control })
    // fieldState is a ComputedRef - updates automatically ✅
    </script>
    <template>
      <span v-if="fieldState.error">{{ fieldState.error }}</span>
    </template>
  2. Use formState.value.errors - Pass as prop from parent:

    vue
    <CustomInput :error="formState.value.errors.email" />
  3. Use Form Context with formState:

    vue
    <script setup>
    const { formState } = useFormContext()
    </script>
    <template>
      <span v-if="formState.value.errors[props.name]">
        {{ formState.value.errors[props.name] }}
      </span>
    </template>

The examples below demonstrate the correct reactive patterns.

computed() Does NOT Help

You might think wrapping getFieldState() in computed() would make it reactive:

typescript
// ❌ Still NOT reactive - computed only runs once
const fieldState = computed(() => getFieldState(props.name))

This doesn't work because getFieldState() reads from plain objects, not reactive refs. The computed will only re-run when its reactive dependencies change, but getFieldState() has none.

Always use useController for reactive field state in custom components.

Quick Integration with Controlled Mode

For simple custom components, use controlled mode:

vue
<!-- Parent form -->
<script setup>
import { useForm } from '@vuehookform/core'
import TextInput from './TextInput.vue'

const { register, handleSubmit, formState } = useForm({ schema })

const { value: emailValue, ...emailBindings } = register('email', { controlled: true })
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <TextInput
      v-model="emailValue"
      v-bind="emailBindings"
      label="Email"
      :error="formState.value.errors.email"
    />
  </form>
</template>
vue
<!-- TextInput.vue -->
<script setup>
defineProps<{
  modelValue: string
  label?: string
  error?: string
}>()

defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <div class="field">
    <label v-if="label">{{ label }}</label>
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
    />
    <span v-if="error" class="error">{{ error }}</span>
  </div>
</template>

Building with useController

For more control, use useController:

vue
<!-- FormInput.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'

const props = defineProps<{
  name: string
  control: LooseControl
  label?: string
  type?: string
  placeholder?: string
}>()

const { field, fieldState } = useController({
  name: props.name,
  control: props.control,
})
</script>

<template>
  <div class="form-input">
    <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"
    />

    <p v-if="fieldState.error" class="error-message" role="alert">
      {{ fieldState.error }}
    </p>

    <p v-if="fieldState.isTouched && !fieldState.error" class="success-message"></p>
  </div>
</template>

<style scoped>
.form-input {
  margin-bottom: 1rem;
}
.label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
}
input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
input.has-error {
  border-color: #dc2626;
}
.error-message {
  color: #dc2626;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}
.success-message {
  color: #16a34a;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}
</style>

Usage:

vue
<script setup>
import { useForm } from '@vuehookform/core'
import FormInput from './FormInput.vue'

const { control, handleSubmit } = useForm({ schema })
</script>

<template>
  <form @submit="handleSubmit(onSubmit)">
    <FormInput name="email" :control="control" label="Email" type="email" />
    <FormInput name="password" :control="control" label="Password" type="password" />
    <button type="submit">Submit</button>
  </form>
</template>

Using Form Context

For deeply nested components, use provideForm and useFormContext:

vue
<!-- Form wrapper -->
<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)">
    <slot />
  </form>
</template>
vue
<!-- Deeply nested component -->
<script setup>
import { useFormContext } from '@vuehookform/core'

const props = defineProps<{
  name: string
  label?: string
}>()

const { register, formState } = useFormContext()
</script>

<template>
  <div class="field">
    <label v-if="label">{{ label }}</label>
    <input v-bind="register(name)" />
    <span v-if="formState.value.errors[name]" class="error">
      {{ formState.value.errors[name] }}
    </span>
  </div>
</template>

Error Handling

If useFormContext() is called outside a provideForm() tree, it throws an error:

"useFormContext must be used within a component tree where provideForm() has been called."

Always ensure your form field components are descendants of a component that calls provideForm().

Select Component

vue
<!-- FormSelect.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'

interface Option {
  value: string
  label: string
}

const props = defineProps<{
  name: string
  control: LooseControl
  options: Option[]
  label?: string
  placeholder?: string
}>()

const { field, fieldState } = useController({
  name: props.name,
  control: props.control,
})
</script>

<template>
  <div class="form-select">
    <label v-if="label" :for="name">{{ label }}</label>

    <select
      :id="name"
      :name="field.name"
      :value="field.value"
      :class="{ 'has-error': fieldState.error }"
      @change="field.onChange(($event.target as HTMLSelectElement).value)"
      @blur="field.onBlur"
    >
      <option v-if="placeholder" value="" disabled>
        {{ placeholder }}
      </option>
      <option v-for="option in options" :key="option.value" :value="option.value">
        {{ option.label }}
      </option>
    </select>

    <p v-if="fieldState.error" class="error-message">
      {{ fieldState.error }}
    </p>
  </div>
</template>

Checkbox Component

vue
<!-- FormCheckbox.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'

const props = defineProps<{
  name: string
  control: LooseControl
  label: string
}>()

const { field, fieldState } = useController({
  name: props.name,
  control: props.control,
})
</script>

<template>
  <div class="form-checkbox">
    <label class="checkbox-label">
      <input
        type="checkbox"
        :name="field.name"
        :checked="field.value"
        @change="field.onChange(($event.target as HTMLInputElement).checked)"
        @blur="field.onBlur"
      />
      <span class="checkbox-text">{{ label }}</span>
    </label>

    <p v-if="fieldState.error" class="error-message">
      {{ fieldState.error }}
    </p>
  </div>
</template>

Radio Group Component

vue
<!-- FormRadioGroup.vue -->
<script setup lang="ts">
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'

interface Option {
  value: string
  label: string
}

const props = defineProps<{
  name: string
  control: LooseControl
  options: Option[]
  label?: string
}>()

const { field, fieldState } = useController({
  name: props.name,
  control: props.control,
})
</script>

<template>
  <fieldset class="form-radio-group">
    <legend v-if="label">{{ label }}</legend>

    <label v-for="option in options" :key="option.value" class="radio-option">
      <input
        type="radio"
        :name="field.name"
        :value="option.value"
        :checked="field.value === option.value"
        @change="field.onChange(option.value)"
        @blur="field.onBlur"
      />
      <span>{{ option.label }}</span>
    </label>

    <p v-if="fieldState.error" class="error-message">
      {{ fieldState.error }}
    </p>
  </fieldset>
</template>

TypeScript Support

Add proper typing to your custom components:

For components that work with any form, use LooseControl:

typescript
import type { LooseControl } from '@vuehookform/core'

// Reusable component props
interface FormFieldProps {
  name: string
  control: LooseControl
  label?: string
}

// Usage with defineProps
const props = defineProps<FormFieldProps>()

Type-Safe Components

For components tied to a specific form schema:

typescript
import type { UseFormReturn, Path } from '@vuehookform/core'

// Generic component props with full type safety
interface FormFieldProps<TSchema extends ZodType> {
  name: Path<InferSchema<TSchema>>
  control: UseFormReturn<TSchema>
  label?: string
}

// Usage with defineProps
const props = defineProps<FormFieldProps<typeof schema>>()

When to Use Each Approach

  • LooseControl: Reusable components in component libraries, shared across projects
  • Typed Control: Components specific to one form with full autocomplete for field names

Re-render Behavior with Custom Components

Understanding when your custom components re-render helps optimize performance.

When useController Components Re-render

Components using useController re-render when:

EventCauses Re-render?Why
User types in the fieldYesfield.value is reactive
Field validation runsOnly if error changesfieldState.error updates
Other fields changeNoIsolated field state
Form submission starts/endsNoUnless watching isSubmitting
setValue() called on fieldYesValue changed externally

Optimizing Re-renders with Validation Modes

Choose the right validation mode to control re-render frequency:

typescript
// Minimal re-renders: validate only on submit
useForm({ schema, mode: 'onSubmit' })
// Re-renders: only when user types (value change)

// Balanced: validate on blur
useForm({ schema, mode: 'onBlur' })
// Re-renders: typing + one validation per field blur

// Maximum feedback, more re-renders: validate on every change
useForm({ schema, mode: 'onChange' })
// Re-renders: typing + validation on each keystroke

// Smart progression: submit first, then real-time feedback
useForm({ schema, mode: 'onSubmit', reValidateMode: 'onChange' })
// Re-renders: typing only, then typing + validation after first submit

Example: Custom Component with Render Tracking

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useController } from '@vuehookform/core'
import type { LooseControl } from '@vuehookform/core'

const props = defineProps<{
  name: string
  control: LooseControl
  label?: string
}>()

const { field, fieldState } = useController({
  name: props.name,
  control: props.control,
})

// Track renders for debugging
const renderCount = ref(0)
renderCount.value++
console.log(`${props.name} rendered: ${renderCount.value} times`)
</script>

<template>
  <div class="custom-input">
    <label v-if="label">{{ label }}</label>
    <input
      :value="field.value"
      @input="field.onChange(($event.target as HTMLInputElement).value)"
      @blur="field.onBlur"
    />
    <p v-if="fieldState.error" class="error">{{ fieldState.error }}</p>
    <small class="debug">Renders: {{ renderCount }}</small>
  </div>
</template>

Comparing Approaches: Re-render Impact

ApproachRe-renders on TypingRe-renders on BlurBest For
register() (uncontrolled)NoOnly if errorNative inputs, max perf
register(..., { controlled: true })YesOnly if errorSimple custom components
useControllerYesOnly if errorThird-party libs, complex

When to Use Each Approach

Use uncontrolled register() when:

  • Using native HTML elements (<input>, <select>, <textarea>)
  • Maximum performance is critical
  • You don't need real-time value access

Use controlled register(..., { controlled: true }) when:

  • Your custom component accepts v-model
  • You need the simplest integration
  • The component follows standard Vue v-model conventions

Use useController when:

  • Integrating third-party UI libraries (PrimeVue, Vuetify, Element Plus)
  • You need explicit control over onChange/onBlur handlers
  • Building reusable form field components
  • The component has non-standard event patterns

Next Steps

Released under the MIT License.