GitHub

Migrating to v2

What changed, what to rename, and what you get for free.

v2 is a near-complete rewrite of the internals. The component names, the validation model, and the array field system all got meaningful upgrades — but the surface area you interact with every day barely changed. Most migrations are a search-and-replace job with a few targeted fixes.

There are no separate v1 docs. The differences are small enough that this page should be all you need.

Why v2?

v1 had a few rough edges that became harder to paper over as the library grew:

  • The form instance was a bag of destructured values. You'd do const { state, id, submit, errors } = useNotForm(...) and end up importing half the instance into every component that needed it.
  • <NotForm> required an id string instead of the instance itself, so the component and the composable were loosely coupled.
  • <NotField> only got its form context via provide/inject — no way to use a field standalone without some creative workarounds.
  • Array field keys looked stable but weren't guaranteed to survive a swap or move, which caused subtle DOM thrashing.
  • validateOn was an array of strings (['blur', 'input']) with no way to override individual triggers per field.

v2 fixes all of that and adds a few things that weren't possible before: per-field trigger overrides, a swap/move API for array fields, an errorsMap for direct template access, and a proper singleton pattern for fields that live outside a form context.

Quick reference

Everything below in one place.

useNotForm

v1v2
initialStateinitialValues
mode: 'lazy' | 'eager'validationMode: { eager?: boolean, lazy?: boolean }
validateOn: string[]validateOn: Partial<Record<ValidationTrigger, boolean>>
onValidate callbackremoved
onReset callbackremoved
onError callbackremoved
Returns { state, id, submit, ... }Returns a single form instance
state (Ref<Input>, needs .value)form.values (plain reactive, no .value)
setState(partial, validate?)form.setValue(path, value)
setErrors(issues)form.setErrors(issues)
reset(state?, errors?, validate?)form.reset(values?, errors?) (no validate param)
touchAllFields() / dirtyAllFields()removed — called internally on submit
No errorsMapform.errorsMap — flat computed map of path → first error

<NotForm>

v1v2
:id="id":form="form"
as / asChild propsattribute forwarding (pass class, novalidate, etc. directly)
Default slot exposes form contextDefault slot has no slot props

<NotField>

v1v2
name proppath prop
v-slot="{ methods }"v-slot="{ events }"
errors: string[]errors: StandardSchemaV1.Issue[] (use .message)
No :form propOptional :form prop — singleton pattern
No validateOn propvalidateOn prop — per-field trigger overrides
No isValidating slot propisValidating slot prop

<NotArrayField>

v1v2
name proppath prop
:schema prop required:item-schema prop optional
fields slot propitems slot prop
field.value, field.first, field.lastremoved from item shape
No swap / moveswap(a, b) and move(from, to)
Keys not guaranteed across reordersKeys are stable across all mutations

<NotMessage>

v1v2
name proppath prop
No :form propOptional :form prop
No slot propsv-slot="{ message, attributes }"

Step by step

1. Update useNotForm

The biggest change is that the composable now returns a single form object instead of a bag of individual refs. Stop destructuring.

// v1
const { state, id, submit, errors, isValid } = useNotForm({ ... })

// v2
const form = useNotForm({ ... })
// form.values, form.submit, form.errors, form.isValid, ...

Rename initialState to initialValues. The value is still deeply cloned on init — that hasn't changed.

// v1
useNotForm({ schema, initialState: { email: '' } })

// v2
useNotForm({ schema, initialValues: { email: '' } })

validateOn changed from an array of strings to an object of booleans. The same three defaults apply (onBlur, onChange, onInput all true), you just express overrides differently now.

// v1
useNotForm({ schema, validateOn: ['blur', 'change'] }) // input is implicitly off

// v2
useNotForm({ schema, validateOn: { onInput: false } }) // only disable what you don't want

mode became validationMode and is now an object too.

// v1
useNotForm({ schema, mode: 'lazy' })

// v2
useNotForm({ schema, validationMode: { lazy: true } })

onValidate, onReset, and onError are gone. If you were using them:

  • onValidate — run your extra validation inside onSubmit, or call form.validate() manually and inspect result.issues.
  • onReset — use a watch on form.isDirty or trigger your logic from the same button handler.
  • onError — read form.errors reactively, or check result.issues after await form.validate().

setState is replaced by setValue, which takes a dot-separated path instead of a partial object. This keeps it consistent with how every other API in v2 addresses fields.

// v1
form.setState({ address: { city: 'Lagos' } })

// v2
form.setValue('address.city', 'Lagos')

touchAllFields() and dirtyAllFields() are removed. They were only useful to call before programmatic submit — form.submit() does that internally now, so there's nothing to replace.

reset no longer accepts a validate third argument. If you need to validate after a reset, call form.validate() yourself.

// v1
form.reset(newValues, [], true)

// v2
form.reset(newValues)
await form.validate()

state was a Ref<Input> and needed .value in script context. form.values is a plain reactive object — no .value anywhere.

// v1 (script)
state.value.email = 'test@example.com'

// v2 (script)
form.values.email = 'test@example.com'

Templates change too — state.email becomes form.values.email.

2. Update <NotForm>

Drop the :id prop and pass the instance directly.

<template>
  <!-- v1 -->
  <NotForm :id="id" @submit="submit">

  <!-- v2 -->
  <NotForm :form="form" @submit="form.submit">
</template>

If you were using as or asChild, just forward attributes directly. <NotForm> passes anything it doesn't recognise through to the underlying <form>.

<template>
  <!-- v1 — rendering as a div -->
  <NotForm :id="id" as="div">

  <!-- v2 — there is no as/asChild; NotForm always renders a <form>. -->
  <!-- If you truly need a different element, render it yourself and use the singleton pattern. -->
</template>

The default slot no longer exposes form context as slot props. If you were pulling state or submit from the slot, read them off form directly.

<template>
  <!-- v1 -->
  <NotForm :id="id" v-slot="{ state, submit }">
    <input v-model="state.email" />
  </NotForm>

  <!-- v2 -->
  <NotForm :form="form" @submit="form.submit">
    <input v-model="form.values.email" />
  </NotForm>
</template>

3. Update <NotField>

Rename name to path. Rename methods to events in the slot.

<template>
  <!-- v1 -->
  <NotField name="email" v-slot="{ methods, name, errors }">
    <input v-model="state.email" v-bind="methods" />
    <span>{{ errors[0] }}</span>
  </NotField>

  <!-- v2 -->
  <NotField path="email" v-slot="{ events, errors }">
    <input v-model="form.values.email" v-bind="events" />
    <span>{{ errors[0].message }}</span>
  </NotField>
</template>

Note the errors change — v1 gave you string[], v2 gives you StandardSchemaV1.Issue[]. Add .message wherever you were reading error text directly.

4. Update <NotArrayField>

Rename name to path. Rename schema to item-schema (and make it optional — it's only needed for TypeScript inference on mutation methods now).

Rename the fields slot prop to items. Remove any references to field.value, field.first, or field.last — those properties are no longer on the item shape.

Update your nested <NotField> paths — v1 used manual index interpolation, v2 hands you the full path directly.

<template>
  <!-- v1 -->
  <NotArrayField name="hobbies" :schema="schema.shape.hobbies" v-slot="{ fields, append, remove }">
    <div v-for="(field, index) in fields" :key="field.key">
      <NotField :name="`hobbies.${index}`" v-slot="{ methods }">
        <input v-model="state.hobbies[index]" v-bind="methods" />
      </NotField>
      <button type="button" @click="remove(index)">Remove</button>
    </div>
    <button type="button" @click="append('')">Add</button>
  </NotArrayField>

  <!-- v2 -->
  <NotArrayField path="hobbies" v-slot="{ items, append, remove }">
    <div v-for="item in items" :key="item.key">
      <NotField :path="item.path" v-slot="{ events }">
        <input v-model="form.values.hobbies[item.index]" v-bind="events" />
      </NotField>
      <button type="button" @click="remove(item.index)">Remove</button>
    </div>
    <button type="button" @click="append('')">Add</button>
  </NotArrayField>
</template>

You also get swap and move for free now — no workaround needed.

5. Update <NotMessage>

Rename name to path. That's it for the common case.

<template>
  <!-- v1 -->
  <NotMessage :name="name" />

  <!-- v2 -->
  <NotMessage path="email" />
</template>

New things you should know about

These didn't exist in v1. You don't have to use them, but they solve real problems.

form.errorsMap — a flat computed Record<path, string> of the first error per field. Useful for direct template reads without calling getFieldErrors.

<template>
  <p>{{ form.errorsMap['address.city'] }}</p>
</template>

Per-field validateOn on <NotField> — override individual triggers without touching the form config. The form defaults still apply for anything you don't specify.

<template>
  <!-- Validate on every keystroke for this field only -->
  <NotField path="username" :validateOn="{ onInput: true }" v-slot="{ events }">
</template>

Singleton pattern — pass :form directly to <NotField>, <NotMessage>, or <NotArrayField> when they live outside a <NotForm>. No more forced nesting.

<template>
  <NotField :form="form" path="search" v-slot="{ events }">
    <input v-model="form.values.search" v-bind="events" />
  </NotField>
  <NotMessage :form="form" path="search" />
</template>

swap and move on <NotArrayField> — stable reorders without index arithmetic.

<template>
  <button @click="swap(0, 2)">Swap first and third</button>
  <button @click="move(3, 0)">Move item 3 to the top</button>
</template>