npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 ref as 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 up v-model, touched tracking, and blur validation in one shot

Table of Contents

Installation

npm install @vue-reactive-form/core
# or
pnpm add @vue-reactive-form/core
# or
yarn add @vue-reactive-form/core

Peer 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 messages

The $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 $control object (e.g., const { state } = form.name.$control). Because $control properties 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 $control reference.

If you really need to destructure the control object, you can do so by using Vue's toRefs utility 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 // true

Nested 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 items

Arrays 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 // 25

Iterating 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 schema

Validate 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 // true

API

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" // write

When 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 // true

InputControl.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 // true

Note: When using handleSubmit, all accessed fields are automatically marked as touched before validation. This means you can safely gate error visibility behind touched — 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 // false

InputControl.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 // undefined

InputControl.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 // false

InputControl.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 item

ArrayInputControl.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 typed

For 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 | undefined

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