`s with no native control at all, has no single element to anchor. Attaform notices, steps back from the single-element latch, and tracks focus at the widget root instead, so the field still knows when the reader is inside it. Tabbing between a widget's own parts stays focused; leaving it reads as a blur.
## When a component has more than one root
Vue hands a runtime directive to a component only when that component renders a **single element root**. A component whose template root is a fragment (several sibling nodes, as some combobox and select roots are) never receives the directive at all.
The value channel still works, because that rides the component's props and emits. What you lose is the directive half: the field's `connected` bit, focus tracking, aria, and scroll-to-error. In development, Attaform notices a value arriving from a component it never attached to and points it out, so the gap is loud rather than silent.
The fix is to give the directive a single element to land on:
```vue
```
or reach for the component's own single-root option if it offers one.
## When the value lives somewhere else
Most Vue components speak the standard `modelValue` / `update:modelValue` contract, and those bind with nothing extra. For the ones that do not, Attaform has a tool sized to each case:
- **A different model name.** A component that emits `value` / `update:value` instead of the standard pair will not see Attaform's `modelValue`. Wrap it in a thin component that maps one to the other, then bind the wrapper. A few lines of composition, and it joins the happy path.
- **A value that is not a DOM property.** A widget that keeps its value on a method, a dataset attribute, or a custom property needs [a custom assigner](/docs/binding-inputs/custom-assigners): a per-element write function that decides what lands in form state.
- **A wrapper you wrote yourself.** When the component is your own and its root is not the input, [`useRegister`](/docs/binding-inputs/use-register) re-binds the parent's `v-register` onto the inner element directly. It is the most direct path whenever you can edit the component.
## Tested against real libraries
Attaform does not formally promise to support every component library in existence. What it offers is a set of careful heuristics, run at the directive and the compile-time transform, that make most standard Vue components bind without ceremony, plus the escape hatches above for the rest.
The demos above run reka-ui and PrimeVue live. Behind them, the cross-library test suite exercises `v-register` against the real rendered output of all three, [reka-ui](https://reka-ui.com), [PrimeVue](https://primevue.org), and [Nuxt UI](https://ui.nuxt.com): single-input fields, spinbuttons, composite PIN inputs, control-less sliders, and fragment-rooted comboboxes each land in the matrix, so the behavior described on this page is pinned, not promised.
## Where to next
- [The `v-register` directive](/docs/binding-inputs/v-register): the directive doing the reaching, and the accessibility it wires.
- [`useRegister`](/docs/binding-inputs/use-register): re-bind `v-register` inside a wrapper component you author.
- [Custom assigners](/docs/binding-inputs/custom-assigners): the write hook for non-standard value surfaces.
- [`injectForm`](/docs/cross-cutting-state/inject-form): for compound components binding several paths at once.
_Source: https://attaform.dev/docs/binding-inputs/third-party-components_
---
# `setValue` patterns
> The programmatic write surface: three call shapes, a callback option, and the `unset` sentinel for flagging any path blank.
Click the four buttons in the demo to exercise every `setValue` shape: string path, segment tuple, callback, whole-form. The reactive surface (`values`, `fields`, validation) updates the same way it does for directive-driven writes; `setValue` and `v-register` share the pipeline. The [Three call shapes](#three-call-shapes) section unpacks each form.
## Three call shapes
`setValue` accepts whatever shape fits the write site. Each example below assumes a `form` handle from `useForm({ schema })`:
```ts
const form = useForm({ schema })
form.setValue({ name: 'Ada', age: 30 }) // whole-form
form.setValue('profile.email', 'a@b.c') // dotted path
form.setValue(['profile', 'email'], 'a@b.c') // segment tuple
```
The dotted-path form is the most ergonomic for plain object schemas. The segment-tuple form sidesteps the dotted-key collision (a schema key that contains a literal `.`) and gives TypeScript precise typed-tuple inference. The whole-form shape replaces every path at once: useful for hydrating from a server response or applying external state.
## Callback form
Pass a function to read the current value and return the next:
```ts
form.setValue('count', (prev) => (prev ?? 0) + 1)
form.setValue('tags', (prev) => [...prev, 'new-tag'])
form.setValue(['profile', 'name'], (prev) => prev.trim().toLowerCase())
```
The callback receives the current value at the path; its return value lands in storage. Equivalent to reading `form.values.
`, computing the next value, and writing it, in one atomic step.
## Returns `boolean`
`setValue` returns `true` when the write was accepted, `false` when it was rejected by the slim-type gate (value didn't match the leaf's accept set). Reach for the return value when a downstream action depends on the write succeeding:
```ts
if (form.setValue('age', 21)) {
/* proceed */
}
```
## Same pipeline as `v-register`
Every `setValue` call flows through the same write pipeline as the directive: slim-type gating, dirty / touched tracking, persistence, history. The reactive surface (`values`, `fields`, validation) reacts identically. No "programmatic writes are second-class" carve-out.
The one difference: `setValue` writes are **never coerced**. Coercion is for user-typed DOM strings; values you pass to `setValue` are already typed at the call site (TypeScript checks it), so coercion would be a no-op at best and a footgun at worst.
## Flagging a path blank
To flag a path as displayed-empty, pass the `unset` sentinel. The runtime writes the schema's slim value at that path and joins the path to `form.blankPaths` so the bound input renders empty.
```ts
import { unset } from 'attaform'
form.setValue('middleName', unset) // storage holds '', form.blankPaths has 'middleName'
form.setValue('profile', unset) // recursive, marks every primitive descendant
form.setValue('cargo', unset) // DU stub, writes { kind: '' } with no variant body
```
`unset` works at every path the consumer can address: primitive leaves, containers, arrays, tuples, records, discriminated unions, wrappers, and the root. See [the `unset` page](/docs/writing-and-mutating/unset) for the full contract.
## Where to next
- [`reset` & `resetField`](/docs/writing-and-mutating/reset): restore defaults instead of just writing.
- [`clear`](/docs/writing-and-mutating/clear): write blank values without going through defaults.
- [Field-array mutations](/docs/writing-and-mutating/field-arrays): append, insert, remove, swap, move, replace, prepend.
- [`unset`](/docs/writing-and-mutating/unset): the blank-anywhere sentinel for `defaultValues`, `setValue`, and `reset`.
_Source: https://attaform.dev/docs/writing-and-mutating/set-value_
---
# `reset` & `resetField`
> The defaults are the destination: `reset` for the whole form, `resetField` for one path. Both wipe state along with values.
Type into any field to flip it dirty. Click `resetField` to restore one path; click `reset()` to restore everything; click `reset(newDefaults)` to redirect the defaults AND restore in one call. The dirty markers and the form-level pristine/dirty status update reactively as state reset propagates.
## `reset()` restores defaults
```ts
form.reset()
```
Every path goes back to its `defaultValues` entry, or, where no override was given, to the schema-slim default (`''` for strings, `0` for numbers, `false` for booleans). Alongside the value reset:
- `dirty` flips false on every leaf.
- `touched` flips false on every leaf.
- `errors` clear at every path.
- `meta.submissionAttempts`, `meta.submitted`, and `meta.submitError` all zero (the submission surface returns to its initial state).
`reset()` is the right call after a successful submit when you want the form to look fresh, or when a "Discard changes" button needs to back out unsaved edits.
## `reset(nextDefaults)` redirects defaults
Pass a new defaults object to update the form's defaults AND reset in one step:
```ts
form.reset({ name: 'New Default', email: 'new@example.com' })
```
After this call, the new object IS the form's defaults for any subsequent `reset()` or `resetField` call. Useful when the form needs to switch contexts: editing record A then loading record B's values as the new baseline.
The argument is a `Partial>`. Fields you don't mention pick up the previous defaults. Pass `{}` to reset with no changes to the defaults.
## `resetField(path)` restores one path
```ts
form.resetField('email')
```
Same semantics as `reset()`, scoped to a single path:
- Value goes back to the path's default.
- `dirty` and `touched` flip false at that path.
- Errors at that path clear.
Useful when one field's been edited but the user wants to discard just that change while keeping the rest of the form's edits in place.
## Reset vs clear
`reset` and `resetField` go to **defaults**. [`clear`](/docs/writing-and-mutating/clear) goes to **blank**: the schema-slim empty value, skipping defaults. Pick reset when "back to baseline" is the goal; pick clear when "wipe to zero" is.
## Where to next
- [`setValue` patterns](/docs/writing-and-mutating/set-value): write specific values without going through defaults.
- [`clear`](/docs/writing-and-mutating/clear): wipe to blank values, defaults intentionally skipped.
- [Submit lifecycle](/docs/submitting/handle-submit): handlers that pair reset with successful submits.
_Source: https://attaform.dev/docs/writing-and-mutating/reset_
---
# `clear` & blank values
> Wipe to the schema's empty shape, not to defaults. For "blank canvas" UX where defaults would be the wrong destination.
Click the per-field clear buttons to watch each path drop to its schema-slim empty value: `''` for the string title, `[]` for the tags array, `false` for the published boolean. Click `clear()` to wipe everything. The defaults declared on `useForm` are intentionally skipped; that's the distinction from `reset`. The blank flag on each field flips on to mark the cleared state.
## What "blank" means
`clear` writes the **schema-slim** empty value at each cleared path:
| Leaf type | Cleared value |
| ------------- | --------------------- |
| `z.string()` | `''` |
| `z.number()` | `0` |
| `z.boolean()` | `false` |
| `z.array()` | `[]` |
| `z.object()` | `{}` (then descended) |
| `z.date()` | `new Date(0)` |
| `z.file()` | `null` |
Same shapes Attaform uses for the initial defaults when nothing is declared in `defaultValues` or `schema.default(...)`. The blank predicate (`fields..blank`) flips true at every cleared path.
## Clear is not reset
The key distinction: `clear` skips defaults; `reset` restores them. Pick clear when the user-facing intent is "blank canvas": a fresh draft, a wipe, a "start over from nothing". Pick `reset` when the intent is "back to baseline": the form's authoritative starting state.
```ts
form.reset() // values.title === 'A great draft' (the default)
form.clear() // values.title === '' (the schema-slim empty)
```
Both wipe `dirty` / `touched` along with the value; both surface the same reactive pipeline.
## Three call shapes
```ts
form.clear() // whole form
form.clear('profile.email') // dotted path
form.clear(['profile', 'email']) // segment tuple
```
Same call ergonomics as `setValue`. The whole-form call clears every path recursively; the per-path forms scope to one leaf or container.
## Returns `boolean`
`true` on accepted writes, `false` when the slim-type gate rejects (rare; the empty value should always be valid for the path's accept set, but containers with required fields may flag).
## When `clear()` is the right call
- A "Compose new" button in a draft UI where the user expects an empty canvas, not a re-populated form.
- After a successful submit when the next interaction should start from nothing rather than the previous defaults.
- Implementing a "Discard and start over" affordance distinct from "Undo my edits".
For "go back to the baseline this form was hydrated with," reach for [`reset`](/docs/writing-and-mutating/reset) instead.
## Where to next
- [`reset` & `resetField`](/docs/writing-and-mutating/reset): restore defaults instead of clearing.
- [`unset`](/docs/writing-and-mutating/unset): flag any path blank in `defaultValues`, `setValue`, or `reset`.
- [Display state and showing errors](/docs/validation/showing-errors): how blank fields interact with the error-display predicate.
_Source: https://attaform.dev/docs/writing-and-mutating/clear_
---
# `unset`
> A consumer-side shorthand for "this path starts blank." The runtime writes the schema's slim value at that path, joins every primitive descendant to `form.blankPaths`, and the bound input renders empty until the user types.
The demo carries a primitive leaf (`email`) and a container (`profile` with `name` + `age`). Click `setValue('email', unset)` to flag the leaf blank, or `setValue('profile', unset)` to flag the whole container blank. The panel shows storage (always concrete, never the sentinel) and the live `blankPaths` set.
## What `unset` does
`unset` is a sentinel symbol exported from every entry point. Pass it as a value in `defaultValues`, `setValue`, or `reset`. The runtime translates it into two effects at the targeted path:
1. **Storage** receives the schema's slim concrete. For primitive leaves that's `''`, `0`, `0n`, `false`. For `.optional()` wrappers it's `undefined`; for `.nullable()` it's `null`. For container paths the runtime recurses: an object writes a shape with every leaf at its slim, an array writes `[]`, a tuple writes its slim positions, a record writes `{}`, a discriminated union writes `{ : '' }` with no variant body.
2. **`form.blankPaths`** gains every primitive descendant under the target. The `v-register` directive reads from the same set when binding the DOM input, so the field renders empty even though storage holds a concrete value.
Reads through `form.values.` always see the slim value. Storage never holds the sentinel symbol.
## Where `unset` can land
The sentinel works at every position the consumer can address:
```ts
import { unset, useForm } from 'attaform'
const schema = z.object({
email: z.string(),
profile: z.object({ name: z.string(), age: z.number() }),
tags: z.array(z.string()),
cargo: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('boat'), length: z.number() }),
z.object({ kind: z.literal('truck'), payload: z.number() }),
]),
})
const form = useForm({
schema,
defaultValues: {
email: unset, // primitive leaf
profile: unset, // container, marks profile.name and profile.age
tags: unset, // array, writes []
cargo: unset, // DU, writes { kind: '' } stub, no variant body
},
key: 'demo',
})
```
The same value works on every imperative write:
```ts
form.setValue('profile', unset) // re-blank the whole profile sub-tree
form.setValue('cargo', unset) // reset cargo to a no-variant-selected state
form.reset({ tags: unset, email: unset }) // baseline that re-applies on reset()
```
Root-level `unset` is admitted too. `defaultValues: unset` or `form.reset(unset)` walks the whole schema, marking every primitive descendant blank. Useful for "the user must touch every field" workflows where presence carries audit weight: a housing application form that needs to record "client manually set income to 0" separately from "income defaulted to 0."
## Container behavior, in detail
| Position | Storage write | `blankPaths` adds |
| ------------------------------ | -------------------------------------- | ----------------------------------------- |
| Primitive leaf | Schema's slim primitive | The leaf path |
| Bare object | Recursive slim subtree | Every primitive descendant under the path |
| Array / tuple / record | `[]` / slim tuple / `{}` | The container path itself |
| Discriminated union container | `{ : }` | The discriminator's path |
| `.optional()` wrapper | `undefined` | The wrapper path |
| `.nullable()` wrapper | `null` | The wrapper path |
| Date / RegExp / Map / Set leaf | The schema's slim concrete | The leaf path |
The container path itself does NOT enter `form.blankPaths`. `form.fields('profile').blank` derives reactively from the conjunction "every primitive descendant is blank," so an empty container reads blank by vacuous truth, and one descendant filled flips the container's blank false automatically.
## Reading the blank state
`form.values.` returns concrete storage. Read the displayed-empty state through `form.fields`:
```ts
form.fields.email.blank // true after setValue('email', unset)
form.fields('profile').blank // true when every descendant of profile is blank
```
`form.blankPaths.value` exposes a `BlankPathsView` (Set-like: `size`, `has(input)`, `values()`, plus a `Symbol.iterator`) for callers that want the whole list (persistence, debug overlays). The `v-register` directive reads the same signals and renders the DOM empty.
## Required schemas raise `no-value-supplied`
A required leaf sitting in `form.blankPaths` surfaces an error in `form.errors`:
```ts
const schema = z.object({ income: z.number() }) // required
const form = useForm({ schema, defaultValues: { income: unset } })
form.errors.income // [{ code: 'atta:no-value-supplied', … }]
form.fields('income').blank // true
```
`.optional()`, `.nullable()`, and `.default(N)` schemas accept the empty case. Those leaves still sit in `form.blankPaths` (the directive needs the signal to render the input empty), but no error fires. See [the `blank` field-state bit](/docs/validation/blank) for the full lifecycle.
## Cross-entry availability
`unset` and `isUnset` ship from every entry point: `attaform`, `attaform/zod`, `attaform/zod-v3`, `attaform/zod-v4`. The `Unset` type is exported alongside for explicit type annotations:
```ts
import { unset, isUnset, type Unset } from 'attaform'
function maybeBlank(v: string | Unset): string | undefined {
return isUnset(v) ? undefined : v
}
```
`isUnset` is a type guard for explicit narrowing of `value: T | Unset` payloads (input parameters, `setValue` callbacks). It is NOT a `form.values` check: storage never holds the symbol, so `isUnset(form.values.)` always returns `false`.
## Where to next
- [The `blank` field-state bit](/docs/validation/blank): the storage / display side-channel `unset` plugs into.
- [`setValue` patterns](/docs/writing-and-mutating/set-value): the imperative write surface.
- [Defaults from the schema](/docs/schemas/defaults): how schema-declared defaults and `unset` interact at mount time.
_Source: https://attaform.dev/docs/writing-and-mutating/unset_
---
# Field-array mutations
> Seven shape-changing helpers, typed against every array path in your schema. Stable per-item identity across moves, removes, and swaps.
Use the row arrows and per-row × button to move and remove items; the buttons below dispatch every helper against the array. Watch the readout: the order, count, and contents reflect each call. Stable keys mean per-item validation state, dirty bits, and DOM focus survive shape changes; reordering doesn't reset what the user typed.
## The seven helpers
Each helper is typed against the form's `ArrayPath