Migrating to v2
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 anidstring 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.
validateOnwas 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
| v1 | v2 |
|---|---|
initialState | initialValues |
mode: 'lazy' | 'eager' | validationMode: { eager?: boolean, lazy?: boolean } |
validateOn: string[] | validateOn: Partial<Record<ValidationTrigger, boolean>> |
onValidate callback | removed |
onReset callback | removed |
onError callback | removed |
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 errorsMap | form.errorsMap — flat computed map of path → first error |
<NotForm>
| v1 | v2 |
|---|---|
:id="id" | :form="form" |
as / asChild props | attribute forwarding (pass class, novalidate, etc. directly) |
| Default slot exposes form context | Default slot has no slot props |
<NotField>
| v1 | v2 |
|---|---|
name prop | path prop |
v-slot="{ methods }" | v-slot="{ events }" |
errors: string[] | errors: StandardSchemaV1.Issue[] (use .message) |
No :form prop | Optional :form prop — singleton pattern |
No validateOn prop | validateOn prop — per-field trigger overrides |
No isValidating slot prop | isValidating slot prop |
<NotArrayField>
| v1 | v2 |
|---|---|
name prop | path prop |
:schema prop required | :item-schema prop optional |
fields slot prop | items slot prop |
field.value, field.first, field.last | removed from item shape |
No swap / move | swap(a, b) and move(from, to) |
| Keys not guaranteed across reorders | Keys are stable across all mutations |
<NotMessage>
| v1 | v2 |
|---|---|
name prop | path prop |
No :form prop | Optional :form prop |
| No slot props | v-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 insideonSubmit, or callform.validate()manually and inspectresult.issues.onReset— use awatchonform.isDirtyor trigger your logic from the same button handler.onError— readform.errorsreactively, or checkresult.issuesafterawait 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>