Skip to content

Vue Hook FormTypeScript-first Form Library

One composable. Schema-driven. Perfect types.

Quick Example

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

Type-Safe Nested Paths

Your IDE knows every field path. Invalid paths cause TypeScript errors at compile time.

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

Dynamic Field Arrays

Built-in array management with stable keys. No external libraries needed.

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

Why Vue Hook Form?

The Problem

Building forms in Vue often means choosing between:

  • Component-based libraries that add 50kb+ to your bundle
  • Field-level composables that scatter state across your codebase
  • Rolling your own and reinventing validation, arrays, and types

The Solution

Vue Hook Form takes a different approach:

  • Form-level state - One useForm() composable manages everything. Cross-field validation? Simple. Form-wide state? One object.
  • Zod-native - Your schema is the source of truth for types AND validation. No adapters, no plugins.
  • Performance by default - Uncontrolled inputs mean zero re-renders during typing. Opt into reactivity only where you need it.
FeatureVue Hook FormVeeValidateFormKit
Bundle~10kb~15kb~50kb
API StyleForm-levelField-levelComponent
Zod SupportNativeAdapterLimited
TypeScriptPerfectGoodGood

Validation When You Want It

Four validation modes to match your UX needs:

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

Quick Reference

WrongRight
items[0].nameitems.0.name
:key="index":key="field.key"
formState.errorsformState.value.errors
v-model + register()Either one, not both

Real-World Examples

Released under the MIT License.