@vue-reactive-form/core
v1.7.1
Published
Lightweight, type-safe form validation library for Vue 3 that leverages Vue's reactivity system to provide a seamless and flexible way to manage form state and validation without depending on strings to link inputs to the form.
Readme
vue-reactive-form
Lightweight, type-safe form state management and validation for Vue 3. Leverages Vue's reactivity system to provide a seamless, flexible way to manage form state and validation - without depending on strings to link inputs to the form.
🚀 Get started in minutes - no boilerplate, no learning curve. Just reactive forms that work.
Check out the live demo for a hands-on example.
Killer Features:
- Lightweight - Minimal footprint, focused on form state and validation without unnecessary bloat
- Type-safe - Built with TypeScript, ensuring your form values and validation rules are fully typed. Navigate nested form state with full autocompletion
- Standard Schema validation - Compatible with Standard Schema, making it work out of the box with Yup, Zod, Valibot, and others
- Headless & UI agnostic - Core form logic and state management without enforcing any UI components or styles. Works with any UI library or custom design system
- Reactive validation schema - Pass a
refas your validation schema and swap validation rules dynamically at runtime - Proxy-based tree navigation - Access any field's control at any depth via dot-path notation (
form.user.profile.name.$control), no string paths needed - Validate on blur - Opt into field-level validation triggered on blur, or call
validate()on any individual field's control - Zero-boilerplate bindings - Use
v-bind="form.name.$control.field"to wire upv-model, touched tracking, and blur validation in one shot
Table of Contents
- vue-reactive-form
Installation
npm install @vue-reactive-form/core
# or
pnpm add @vue-reactive-form/core
# or
yarn add @vue-reactive-form/corePeer dependency: @vue/reactivity ^3.4.0 (included with Vue 3).
Getting Started
Define your initial state, bind to controls, and submit.
<script setup lang="ts">
import { useForm } from "@vue-reactive-form/core"
// 1. Define your form state
const { form, handleSubmit } = useForm({
name: "",
email: ""
})
// 2. Create a submit handler
const onSubmit = handleSubmit({
onSuccess: (data) => {
console.log("Form submitted:", data)
}
})
</script>
<template>
<form @submit.prevent="onSubmit">
<div>
<label>Name</label>
<!-- 3. Bind to form controls -->
<input v-model="form.name.$control.state" type="text" />
</div>
<div>
<label>Email</label>
<input v-model="form.email.$control.state" type="email" />
</div>
<button type="submit">Submit</button>
</form>
</template>Understanding the $control Pattern
Every node in the form tree exposes a $control object, which is an InputControl (or ArrayInputControl for arrays). This is your gateway to the field's reactive state and metadata:
form.name.$control.state // Current value (read/write)
form.name.$control.defaultState // Default value (read-only)
form.name.$control.dirty // true if value differs from default
form.name.$control.touched // true after setAsTouched() is called
form.name.$control.isValid // true when no validation errors
form.name.$control.errorMessages // string[] of error messagesThe $control is available at every level of the form tree - from the root, through nested objects, down to individual array elements.
⚠️ Important Reactivity Rule: Do not destructure the
$controlobject (e.g.,const { state } = form.name.$control). Because$controlproperties are implemented using Vue's reactive getters under the hood, destructuring will break reactivity and capture static values at the moment of destructuring. Always access properties directly on the$controlreference.If you really need to destructure the control object, you can do so by using Vue's
toRefsutility to maintain reactivity:const { state, isValid } = toRefs(form.name.$control).
Form State
Primitive Root State
The root state doesn't have to be an object. It can be a primitive value:
const { form } = useForm("Hello World")
form.$control.state // "Hello World"Object State
Object state lets you navigate to each property's control:
const { form } = useForm({ name: "John", age: 30 })
form.name.$control.state // "John"
form.age.$control.state // 30
// Update a field
form.name.$control.state = "Jane"
form.name.$control.dirty // trueNested Objects
Navigate arbitrarily deep nested structures seamlessly:
const { form } = useForm({
user: {
profile: {
name: "John",
email: "[email protected]"
}
}
})
form.user.profile.name.$control.state // "John"
form.user.profile.email.$control.state // "[email protected]"Each intermediate node also has a $control:
// Access the entire profile object
form.user.profile.$control.state // { name: "John", email: "[email protected]" }
// Replace the entire profile at once
form.user.profile.$control.state = {
name: "Jane",
email: "[email protected]"
}Arrays
Array fields support index-based access and have additional array-specific methods via ArrayInputControl:
const { form } = useForm({
tags: ["javascript", "vue", "typescript"]
})
// Access the whole array
form.tags.$control.state
// Access individual elements
form.tags[0].$control.state
// Array operations
form.tags.$control.add("react") // Append an item
form.tags.$control.remove(0) // Remove item at index
form.tags.$control.moveItem(0, 2) // Reorder itemsArrays of objects work the same way:
const { form } = useForm({
users: [
{ name: "John", age: 30 },
{ name: "Jane", age: 25 }
]
})
form.users[0].name.$control.state // "John"
form.users[1].age.$control.state // 25Iterating Over Arrays
Array form nodes are iterable, so you can loop over them in templates or scripts:
<template>
<div v-for="(member, index) in form.teamMembers" :key="index">
<input v-bind="member.name.$control.field" />
<button @click="form.teamMembers.$control.remove(index)">Remove</button>
</div>
<button @click="form.teamMembers.$control.add()">Add Member</button>
</template>Or in script:
for (const item of form.items) {
console.log(item.$control.state)
}Validation
Standard Schema Support
vue-reactive-form supports Standard Schema (@standard-schema/spec), making it compatible with popular validation libraries like Yup, Zod, and Valibot out of the box.
<script setup lang="ts">
import { useForm } from "@vue-reactive-form/core"
import { object, string } from "yup"
const schema = object({
name: string().required("Name is required"),
email: string().email("Invalid email").required("Email is required")
})
const { form, handleSubmit } = useForm(
{ name: "", email: "" },
{ validationSchema: schema }
)
const onSubmit = handleSubmit({
onSuccess: (data) => console.log("Valid:", data),
onError: (errors) => console.log("Errors:", errors)
})
</script>
<template>
<form @submit.prevent="onSubmit">
<div>
<label>Name</label>
<input v-bind="form.name.$control.field" type="text" />
<span v-if="form.name.$control.touched && !form.name.$control.isValid">
{{ form.name.$control.errorMessages.join(", ") }}
</span>
</div>
<div>
<label>Email</label>
<input v-bind="form.email.$control.field" type="email" />
<span v-if="form.email.$control.touched && !form.email.$control.isValid">
{{ form.email.$control.errorMessages.join(", ") }}
</span>
</div>
<button type="submit">Submit</button>
</form>
</template>Reactive Validation Schema
The validation schema can be a Ref, allowing you to swap schemas dynamically at runtime:
import { ref } from "vue"
import { useForm } from "@vue-reactive-form/core"
import * as yup from "yup"
const lenientSchema = yup.object({ name: yup.string() })
const strictSchema = yup.object({
name: yup.string().required("Name is required")
})
const schemaRef = ref(lenientSchema)
const { form, validate } = useForm(
{ name: "" },
{ validationSchema: schemaRef }
)
// Validates successfully with lenient schema
await validate() // ✅ { name: "" }
// Switch to strict schema
schemaRef.value = strictSchema
// Now validation fails
await validate() // ❌ undefined - form.name.$control.errorMessages === ["Name is required"]You can even start with undefined and set a schema later:
const schemaRef = ref(undefined)
const { validate } = useForm({ name: "" }, { validationSchema: schemaRef })
await validate() // ✅ Returns state as-is (no schema = no validation)
schemaRef.value = yup.object({ name: yup.string().required("Required") })
await validate() // ❌ Now validates against the schemaValidate on Blur
By default, validation only runs on submit. Set validateOn: "blur" to also validate individual fields when they lose focus. This works automatically when using the field binding:
<script setup lang="ts">
import { useForm } from "@vue-reactive-form/core"
import { object, string } from "yup"
const schema = object({
name: string().required("Name is required"),
email: string().email("Invalid email").required("Email is required")
})
const { form, handleSubmit } = useForm(
{ name: "", email: "" },
{ validationSchema: schema, validateOn: "blur" }
)
const onSubmit = handleSubmit({
onSuccess: (data) => console.log("Valid:", data)
})
</script>
<template>
<form @submit.prevent="onSubmit">
<div>
<label>Name</label>
<!-- v-bind="field" wires up v-model, onFocus (touched), and onBlur (validation) -->
<input v-bind="form.name.$control.field" type="text" />
<span v-if="form.name.$control.touched && !form.name.$control.isValid">
{{ form.name.$control.errorMessages.join(", ") }}
</span>
</div>
<div>
<label>Email</label>
<input v-bind="form.email.$control.field" type="email" />
<span v-if="form.email.$control.touched && !form.email.$control.isValid">
{{ form.email.$control.errorMessages.join(", ") }}
</span>
</div>
<button type="submit">Submit</button>
</form>
</template>When validateOn is "blur", only the errors for the blurred field are updated — other fields' errors remain untouched until they are blurred or the form is submitted.
Field-Level Validation
You can trigger validation for a single field imperatively using validate() on its control:
const { form } = useForm(
{ name: "", email: "" },
{ validationSchema: schema }
)
// Validate only the name field
await form.name.$control.validate()
form.name.$control.isValid // false
form.name.$control.errorMessages // ["Name is required"]
// The email field's errors are unaffected
form.email.$control.errorMessages // [] (unchanged)Note: Field-level validation runs the full schema against the entire form state, but only updates the errors for the targeted field's path. This means every call pays the cost of a full schema validation. For forms with expensive schemas (e.g. async checks or very large objects), prefer
validateOn: "submit"or keep your schema lightweight.
Handling Validation Errors
When validation fails, errors are automatically distributed to each field's $control:
const { form, validate } = useForm(
{ name: "", age: -5 },
{
validationSchema: yup.object({
name: yup.string().required("Name is required"),
age: yup.number().min(0, "Age must be positive")
})
}
)
await validate()
form.name.$control.isValid // false
form.name.$control.errorMessages // ["Name is required"]
form.age.$control.isValid // false
form.age.$control.errorMessages // ["Age must be positive"]Note: When you update a field's state, its validation errors are automatically cleared. Errors won't persist until the next validation is performed.
form.name.$control.state = "John"
form.name.$control.isValid // true (errors cleared)
form.name.$control.errorMessages // [] (cleared on change)Submitting
Use handleSubmit to create a submit handler with onSuccess and onError callbacks:
const { handleSubmit } = useForm({ name: "John" }, { validationSchema: schema })
const onSubmit = handleSubmit({
onSuccess: (validatedState) => {
// Called when validation passes
// validatedState is the output of the validation schema
console.log("Submitted:", validatedState)
},
onError: (errors) => {
// Called when validation fails
// errors is a Record<string, ValidationIssue[]>
console.log("Errors:", errors)
}
})Bind it to a form's submit event:
<form @submit="onSubmit">
<!-- ... -->
</form>The handler automatically calls event.preventDefault() when an Event argument is passed. When no validation schema is provided, onSuccess is called with the current form state.
Touched on submit: When handleSubmit is called, all fields that have been accessed through the form tree are automatically marked as touched — before validation runs.
form.name.$control.touched // false
form.email.$control.touched // false
await onSubmit()
form.name.$control.touched // true
form.email.$control.touched // trueAPI
useForm(defaultState, options?)
The main composable. Creates the form tree and returns a FormRoot.
import { useForm } from "@vue-reactive-form/core"
const { form, errors, validate, handleSubmit } = useForm(defaultState, options)Parameters:
| Parameter | Type | Description |
| -------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| defaultState | PartialOrPrimitive<TState> | The initial form state. Can be a primitive, object, or array. Object properties are deeply optional. |
| options | UseFormOptions<TState, TValidatedState> | Optional configuration object. |
Options:
| Option | Type | Description |
| ------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| validationSchema | MaybeRef<StandardSchemaV1 \| undefined> | A Standard Schema–compatible validation schema. Can be a plain value or a Ref for dynamic schema swapping. |
| validateOn | "submit" \| "blur" | When validation is triggered. "submit" (default) validates only on submit. "blur" also validates each field when it loses focus. |
FormRoot
The object returned by useForm. Contains the form tree and methods for validation and submission.
type FormRoot<TState, TValidatedState = TState> = {
form: FormNode<RequiredOrPrimitive<TState>>
errors: Ref<FormErrors>
validate: () => Promise<TValidatedState | undefined>
handleSubmit: HandleFormSubmit<TValidatedState>
}FormRoot.form
The root of the form tree. Provides navigation to all nested fields via proxy-based access. The state type is deeply required to ensure navigation is always possible.
const { form } = useForm({ user: { name: "John" } })
form.user.name.$control.state // "John"FormRoot.errors
A Ref containing all validation errors keyed by dot-notation paths:
type FormErrors = Record<string, ValidationIssue[]>const { errors, validate } = useForm({ name: "" }, { validationSchema: schema })
await validate()
errors.value
// {
// "name": [{ message: "Name is required", path: ["name"] }]
// }FormRoot.validate()
Imperatively runs validation against the current state. Returns the validated state on success, or undefined on failure. Errors are populated in errors and distributed to each field's $control.
const result = await validate()
if (result) {
console.log("Valid:", result)
} else {
console.log("Validation failed")
}FormRoot.handleSubmit(options?)
Creates a submit handler function. Returns an async (event?: Event) => void function suitable for binding to form submit events.
type HandleSubmitOptions<TValidatedState> = {
onSuccess?: (state: TValidatedState) => void
onError?: (errors: FormErrors) => void
}const onSubmit = handleSubmit({
onSuccess: (state) => {
/* handle valid submission */
},
onError: (errors) => {
/* handle validation errors */
}
})FormNode
A node in the form tree. The concrete type depends on the value type:
| Value Type | Node Type | Description |
| ------------------------------------ | ---------------------- | ------------------------------------------------------------------- |
| Primitive (string, number, etc.) | PrimitiveFormNode<T> | Has $control only |
| Plain object | ObjectFormNode<T> | Has $control + named child nodes for each property |
| Array | ArrayFormNode<T> | Has $control (with array methods) + indexed child nodes, iterable |
All form nodes expose a $control property. Object and array nodes also allow further navigation into their children.
// PrimitiveFormNode - leaf node
form.name.$control
// ObjectFormNode - navigate deeper
form.user.$control // control for the entire user object
form.user.name.$control // control for user.name
// ArrayFormNode - index or iterate
form.tags.$control // control for the entire array (with add/remove/moveItem)
form.tags[0].$control // control for the first element
for (const tag of form.tags) {
/* ... */
}InputControl
The core interface exposed by every form node's $control. Provides reactive state, validation status, and mutation methods.
type InputControl<T> = {
state: Ref<PartialOrPrimitive<T> | undefined>
defaultState: ComputedRef<PartialOrPrimitive<T> | undefined>
dirty: ComputedRef<boolean>
touched: ComputedRef<boolean>
isValid: ComputedRef<boolean>
errorMessages: ComputedRef<string[]>
clear: () => void
reset: () => void
updateDefaultState: (newDefault?: PartialOrPrimitive<T>) => void
setAsTouched: () => void
validate: () => Promise<void>
readonly field: FieldProps<T>
}InputControl.state
A writable Ref holding the current value of the field. Use with v-model for two-way binding.
form.name.$control.state // read
form.name.$control.state = "Jane" // writeWhen the state is updated, any existing validation errors for that field are automatically cleared.
InputControl.defaultState
A read-only ComputedRef holding the default/initial value of the field. Used internally for dirty checking.
form.name.$control.defaultState // "John" (initial value)InputControl.dirty
A ComputedRef<boolean> that is true when the current value differs from the default value. Uses deep equality for objects.
form.name.$control.dirty // false initially
form.name.$control.state = "Jane"
form.name.$control.dirty // trueInputControl.touched
A ComputedRef<boolean> that is true after setAsTouched() has been called. Useful for showing validation errors only after user interaction.
form.name.$control.touched // false
form.name.$control.setAsTouched()
form.name.$control.touched // trueNote: When using
handleSubmit, all accessed fields are automatically marked as touched before validation. This means you can safely gate error visibility behindtouched— errors will appear for all fields after the first submit attempt.
InputControl.isValid
A ComputedRef<boolean> that is true when there are no validation errors for this field.
// Before validation
form.name.$control.isValid // true
// After failed validation
await validate()
form.name.$control.isValid // falseInputControl.errorMessages
A ComputedRef<string[]> containing all validation error messages for this field.
await validate()
form.name.$control.errorMessages // ["Name is required"]InputControl.clear()
Sets the field's state to undefined.
form.name.$control.clear()
form.name.$control.state // undefinedInputControl.reset()
Resets the field's state back to its default value.
form.name.$control.state = "modified"
form.name.$control.reset()
form.name.$control.state // "John" (back to default)
form.name.$control.dirty // falseInputControl.updateDefaultState(newDefault?)
Updates the default value for the field. Use with caution - changing the default affects dirty checking.
form.name.$control.updateDefaultState("New Default")
form.name.$control.defaultState // "New Default"InputControl.setAsTouched()
Marks the field as touched. Typically called on focus or blur events to track user interaction. When using the field binding, this is called automatically on focus.
<input
v-model="form.name.$control.state"
@focus="form.name.$control.setAsTouched()"
/>InputControl.validate()
Triggers validation for this specific field. Only the errors relevant to this field's path are updated — other fields' errors remain unchanged.
Performance note: Under the hood, this executes the entire validation schema against the full form state. Each call carries the cost of a full validation pass. Keep this in mind when using
validateOn: "blur"with expensive or async schemas.
await form.name.$control.validate()
form.name.$control.isValid // false
form.name.$control.errorMessages // ["Name is required"]InputControl.field
A read-only object containing props suitable for binding to an input component with v-bind. Provides two-way binding via modelValue / onUpdate:modelValue, marks the field as touched on focus, and triggers validation on blur when validateOn is "blur".
<!-- Instead of wiring up v-model, @focus, and @blur individually: -->
<input v-bind="form.name.$control.field" type="text" />This is equivalent to:
<input
v-model="form.name.$control.state"
@focus="form.name.$control.setAsTouched()"
@blur="form.name.$control.validate()" <!-- only when validateOn is "blur" -->
/>See FieldProps for the type definition.
FieldProps
The type returned by InputControl.field. Contains the props needed to bind a form field to an input component.
type FieldProps<T> = {
readonly modelValue: PartialOrPrimitive<T> | undefined
readonly "onUpdate:modelValue": (value: PartialOrPrimitive<T> | undefined) => void
readonly onFocus: () => void
readonly onBlur: () => void
}| Prop | Description |
| --------------------- | -------------------------------------------------------------------------------------------- |
| modelValue | The current field value (read-only from the binding's perspective). |
| onUpdate:modelValue | Updates the field's state — together with modelValue, this enables v-model via v-bind. |
| onFocus | Marks the field as touched. |
| onBlur | Triggers field-level validation when validateOn is "blur". |
ArrayInputControl
Extends InputControl with array-specific methods. Exposed by $control on array form nodes.
type ArrayInputControl<T extends unknown[]> = InputControl<T> & {
add: (defaultValue?: PartialOrPrimitive<T[number]>) => void
remove: (index: number) => void
moveItem: (fromIndex: number, toIndex: number) => void
}ArrayInputControl.add(defaultValue?)
Appends a new item to the array. Optionally accepts a default value for the new item.
form.tags.$control.add() // adds undefined
form.tags.$control.add("new-tag") // adds "new-tag"
// For arrays of objects
form.users.$control.add({ name: "", age: 0 })ArrayInputControl.remove(index)
Removes the item at the given index.
form.tags.$control.remove(0) // removes first itemArrayInputControl.moveItem(fromIndex, toIndex)
Moves an item from one position to another. Indices are clamped to array bounds.
// Move first item to third position
form.tags.$control.moveItem(0, 2)TypeScript
Typed Forms
useForm infers the form state type from the default state you provide:
const { form } = useForm({ name: "John", age: 30 })
form.name.$control.state // string | undefined - fully typed
form.age.$control.state // number | undefined - fully typedFor forms where the initial state may be partial, provide a type parameter:
type UserForm = {
name: string
email: string
age: number
}
const { form } = useForm<UserForm>({})
form.name.$control.state // string | undefined
form.email.$control.state // string | undefinedSeparate Input and Output Types
When using a validation schema, the validated output type may differ from the input type. Use two type parameters to represent this:
type FormInput = {
name?: string
email?: string
}
type FormOutput = {
name: string
email: string
}
const { form, handleSubmit } = useForm<FormInput, FormOutput>(
{},
{ validationSchema: schema }
)
const onSubmit = handleSubmit({
onSuccess: (state) => {
// state is typed as FormOutput - name and email are guaranteed strings
console.log(state.name, state.email)
}
})Exported Types
All public types are available as named exports:
import type {
// Form root & options
FormRoot,
UseFormOptions,
FormErrors,
HandleSubmitOptions,
HandleFormSubmit,
// Controls
InputControl,
ArrayInputControl,
FieldProps,
// Form tree nodes
FormNode,
ArrayFormNode
} from "@vue-reactive-form/core"Contributing
Contributions are welcome! Feel free to open an issue or submit a pull request if you have ideas for improvements or find any bugs.
License
The package is published under the MIT license.
