@vuehookform/core

TypeScript-first form library for Vue 3, inspired by React Hook Form. Form-level state management with Zod validation.
8,370 8
Install
npm install @vuehookform/core
Latest Version:0.7.0
Vue:3
License:MIT
Last Updated:Mar 27, 2026
Links: GitHub  ·  npm
Maintainer: jonnasson

Vue Hook Form

A TypeScript-first form library for Vue 3, inspired by React Hook Form.

npm version CI License: MIT TypeScript Coverage

Features

  • TypeScript First - Perfect type inference with zero manual typing
  • Field Arrays - Dynamic lists with stable keys built-in
  • Performant - Minimal re-renders using uncontrolled inputs
  • Zod Native - First-class Zod integration for validation
  • Tiny Bundle - ~10kb gzipped, tree-shakable
  • UI Agnostic - Works with any UI library or custom components

Quick Start

npm install @vuehookform/core zod
<script setup lang="ts">
import { useForm } from '@vuehookform/core'
import { z } from 'zod'

const schema = z.object({
  email: z.email('Invalid email'),
  password: z.string().min(8, 'At least 8 characters'),
})

const { register, handleSubmit, formState } = useForm({
  schema,
  mode: 'onBlur',
})

const onSubmit = (data) => {
  console.log(data) // Fully typed: { email: string, password: string }
}
</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>

Key Concepts

Schema as Source of Truth

const userSchema = z.object({
  name: z.string().min(2),
  email: z.email(),
  age: z.number().min(18),
})

type UserForm = z.infer<typeof userSchema>
// { name: string; email: string; age: number }

Dynamic Arrays

<script setup>
const { register, fields } = useForm({ schema })
const addresses = fields('addresses')

// Available methods:
// addresses.append(value)  - Add to end
// addresses.remove(index)  - Remove at index
// addresses.insert(index, value)
// addresses.swap(i, j)
// addresses.move(from, to)
</script>

<template>
  <div v-for="field in addresses.value" :key="field.key">
    <!-- Option 1: Scoped methods (recommended) - type-safe, cleaner -->
    <input v-bind="field.register('street')" />
    <input v-bind="field.register('city')" />

    <!-- Option 2: Manual path building -->
    <input v-bind="register(`addresses.${field.index}.street`)" />

    <button @click="field.remove()">Remove</button>
  </div>
  <button @click="addresses.append({ street: '', city: '' })">Add Address</button>
</template>

Field array items provide scoped methods (field.register(), field.setValue(), field.watch(), etc.) that automatically build the full path with type safety.

Validation Modes

useForm({
  schema,
  mode: 'onSubmit', // Only validate on submit (default)
  // mode: 'onBlur',    // Validate when field loses focus
  // mode: 'onChange',  // Validate on every keystroke
  // mode: 'onTouched', // Validate after field is touched
})
  • disabled: ref(true) - Disable entire form (reactive, blocks submission)
  • shouldUseNativeValidation: true - Enable CSS :valid/:invalid selectors

Tips

Controlled vs Uncontrolled

// Default (uncontrolled) - for native inputs
<input v-bind="register('email')" />

// Controlled - for v-model / custom components
const { value, ...bindings } = register('field', { controlled: true })
<CustomInput v-model="value" v-bind="bindings" />

Common Mistakes

Wrong Right Why
items[0].name items.0.name Always use dot notation for paths
:key="index" :key="field.key" Index can change during reordering
formState.errors formState.value.errors formState is a Ref, must access .value
v-model + register() Either one, not both Causes double binding conflict
const state = getFieldState('x') formState.value.errors.x getFieldState returns snapshot, not reactive
<CustomInput v-bind="register()"/> Use controlled: true or useController Custom components need controlled mode
⚠️ Critical: getFieldState() is NOT Reactive

Problem: Calling getFieldState() once returns a snapshot that never updates.

<!-- ❌ WRONG - Error will persist even after fixing the input -->
<script setup>
const emailState = getFieldState('email')
</script>
<template>
  <span v-if="emailState.error">{{ emailState.error }}</span>
</template>

Solutions:

<!-- ✅ Option 1: Use formState (always reactive) -->
<span v-if="formState.value.errors.email">{{ formState.value.errors.email }}</span>

<!-- ✅ Option 2: Use computed for specific field -->
<script setup>
const emailError = computed(() => formState.value.errors.email)
</script>
<template>
  <span v-if="emailError">{{ emailError }}</span>
</template>

<!-- ✅ Option 3: Use useController for reusable components -->
<script setup>
import { useForm, useController, type Control } from '@vuehookform/core'

// control comes from useForm (pass it as a prop to child components)
const { control } = useForm({ schema })
const { fieldState } = useController({ name: 'email', control })
// fieldState is a ComputedRef that updates automatically
</script>
<template>
  <span v-if="fieldState.value.error">{{ fieldState.value.error }}</span>
</template>

Contributing

Contributions welcome! Feel free to report bugs, suggest features, or submit PRs.

License

MIT

Related Packages

@maroonedog/waypoint

A field's address exists before the component that draws it: typed paths the com...

308 1
@retailcrm/vue-formulario

Vue Formulario is a library, inspired by Vue Formulate , that handles the core l...

86,689 3
@braid/vue-formulate

The easiest way to build forms in Vue.

451,602 2,238
@volverjs/form-vue

Vue 3 Forms with @volverjs/ui-vue

8,780 14
vue-formulate

The easiest way to build forms in Vue with validation and vuex support.

4,367 7

Version History

Version Released License
0.7.0 MIT
0.6.0 MIT
0.5.0 MIT
0.4.7 MIT
0.4.6 MIT