# Attaform documentation > The full text of every Attaform documentation page, concatenated for AI agents. Regenerated from the docs on every build. The curated index lives at https://attaform.dev/llms.txt. --- # Introduction > One Zod schema, one Attaform, one source of truth. Attaform reads a Zod schema and hands back the form, ready to play: reactive `values`, per-field `errors`, a `meta` aggregate, and a `v-register` directive that binds every native input shape. **Your schema is the form.** The schema is the validator. The schema is the API contract. Type-safe end to end. Errors surface at compile time, and the runtime keeps every value matched to what your schema declared. Server errors flow back through the same reactive surface, persistence opts in per field, SSR hydrates without flashes, and the panel inside Nuxt DevTools shows every form on the page. ## How these docs read The sidebar walks top to bottom as a learning narrative: install, schema, bind, validate, submit, persist. Drop in anywhere. Every concept is its own URL, its own definition, its own demo. If you're new, start with the [Quick start](/docs/getting-started/quick-start). If you want our philosophy first, read [Why Attaform](/docs/getting-started/why-attaform); if you want the receipts, [Benchmarks](/docs/comparison/benchmarks) measures Attaform against the Vue form-library field in a real browser. If you're hunting a specific surface, the sidebar's last category, **Reference**, is alphabetical and indexed. Reading with a coding assistant? These docs are machine-readable too. Point it at [`llms.txt`](/llms.txt) for the whole index, or install the [Agent Skill](/docs/reference/ai-agents) so it writes idiomatic Attaform from the first try. The [AI agents](/docs/reference/ai-agents) page lays out every option. _Source: https://attaform.dev/docs/getting-started/introduction_ --- # Why Attaform > Five marks of a great form library. Attaform's North star, top to bottom. Forms look simple from the outside. Inside, they're a thicket of subtle details: blank-value tracking, persistence, sensitive-name protection, SSR, async validation, nested objects and discriminated unions, efficient DOM tracking, errors flowing from validators and your server into one reactive form API. A great library handles every one for you, without making you reach for the type plumbing or wire up the side-channels yourself. These five convictions guide Attaform's design: 1. **Telepathically accurate type safety.** Types flow from your schema into every surface: values, errors, paths, write shapes, etc. We don't do `any`. 2. **A tiny, predictable API that just works.** Small surface, no surprises. If you can't learn the core API in five minutes, it's not good enough. 3. **Stays out of your markup.** One directive, `v-register`, does the heavy lifting. Your `` stays a native ``; nothing sits between the DOM and the form. 4. **Vue conventions, end to end.** Composables, reactivity, directives: the shapes you already know. 5. **The schema drives everything.** Data shape, defaults, validation, errors, and types. All from one Zod schema. Why? Because at the end of the day, Vue and Nuxt devs deserve nice things too. ## One source of truth: your schema Write a Zod schema. That's the source of truth for: - **Types.** Every path, value, error, and write shape is inferred. No `any`, no manual generics, no reaching for the type plumbing whenever you add a field. - **Defaults.** Attaform reads the schema's slim shape (`''` for strings, `0` for numbers, `false` for booleans) and uses it as the storage default. Override per field; don't repeat what the schema already says. - **Validation.** Refinements run synchronously by default; async refinements await before submit dispatches. - **Errors.** Each one lands at the offending path. `form.errors.email` is reactive end-to-end. - **Metadata.** Labels, descriptions, placeholders, and free-form meta attach to schema fields and surface on `form.fields.`. One schema in, full reactive surface out. ## Type-safe end to end Every part of the public surface is typed against your schema: ```ts const schema = z.object({ email: z.email(), age: z.number().int().min(13), }) const form = useForm({ schema }) form.setValue('age', 21) // ok form.setValue('age', 'twenty-one') // type error ``` `form.fields.` knows the exact set of paths in the schema. `form.errors.` is reactive, typed, narrowable. The types follow the form through every state. While the user is typing, `form.values` is wide enough to hold whatever they have put there so far: incomplete fields, undecided discriminators, rehydrated half-filled draft state from yesterday's session. Inside `handleSubmit`, the same data flows through the schema and emerges with literals narrowed, refinements honored, and discriminated unions discriminating. Wide where reality is wide, tight where the schema guarantees it. [Type safety](/docs/reading-the-form/type-safety) walks through the trade. ## Native inputs, Vue directive `v-register` is a Vue directive, not a wrapper component. Your `` stays a native ``; there's no field-component overhead between the DOM and the form. ```vue ``` That's the whole binding. A11y attributes, value sync, focus state, blank tracking. All native. ## Live, layered validation - Per-field on `change`, `blur`, or `submit`. Your call, per form. - Sync refinements fire on the keystroke; async refinements await. - A form's `meta.valid` is _gated_. It only flips true after every active path has resolved at least one validation pass, including the async ones. No flash-of-valid window for users with a slow uniqueness check. - Server-side errors map back into the same reactive form API. The render surface is the same whether the error came from Zod or your API. ## SSR-first, hydration-clean Forms render server-side and hydrate without a flash. Nuxt is zero-config; bare Vue 3 plus `@vue/server-renderer` takes two one-liner helpers. The form your server rendered _is_ the form your client picks up. ## Built into the core These ship with the core, typed and orchestrated as first-class features: - Field arrays with stable keys and per-item validation. - Undo / redo with bounded history, opt-in per form. - Persistence with per-field opt-in, local / session / IndexedDB / custom backends, and sensitive-name guards out of the box. - Discriminated unions with variant memory across discriminator switches. - A DevTools panel that surfaces every form on the page: values, errors, history, persistence drafts. ## Where to next - [Quick start](/docs/getting-started/quick-start): your first form, end-to-end. - [The form](/docs/reading-the-form/the-form): the full reactive surface returned by `useForm`. - [Type safety](/docs/reading-the-form/type-safety): wide while typing, tight inside `handleSubmit`, by design. - [When validation runs](/docs/validation/when-validation-runs): the moment errors appear. - [The `v-register` directive](/docs/binding-inputs/v-register): how Attaform binds inputs. - [Benchmarks](/docs/comparison/benchmarks): how Attaform measures up across the Vue form-library field, in a real browser. _Source: https://attaform.dev/docs/getting-started/why-attaform_ --- # Installation > One command. Attaform bootstraps itself. Install Attaform and Zod, and you're set. That's it for most apps. `useForm` and the `v-register` directive are ready to use as soon as the package is installed. Everything below this section is opt-in. ## Optional: Nuxt module If you're on Nuxt, we recommend installing the module: ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ['attaform/nuxt'], }) ``` What this gets you: the form composables as auto-imports (`useForm`, `useWizard`, `injectForm`, `injectWizard`, `fieldMeta`, `withMeta`, `lazy`; see [Auto-imports](#auto-imports)), the SSR hydration plumbing, the Vite plugin, and the Attaform tab inside Nuxt DevTools. ## Optional: Vue 3 plugin For app-wide defaults (`validateOn`, `debounceMs`, etc.) or explicit control over plugin registration on bare Vue 3, install the plugin in your app entry: ```ts import { createApp } from 'vue' import { createAttaform } from 'attaform' import App from './App.vue' createApp(App) .use(createAttaform({ defaults: { debounceMs: 100 } })) .mount('#app') ``` What this gets you: `createAttaform({ defaults })` for app-wide settings that every `useForm` call inherits. ## Optional: Vite plugin If you're on bare Vue 3 + Vite (not Nuxt), add the plugin to `vite.config.ts`: ```ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { attaform } from 'attaform/vite' export default defineConfig({ plugins: [vue(), attaform()], }) ``` What this gets you: SSR-rendered `v-register` bindings that match the client-rendered output on initial HTML (no flicker between server paint and hydration), and a leaner production bundle (one Zod adapter shipped instead of both). ## Auto-imports Both the Nuxt module and the Vite plugin can register Attaform's form composables as auto-imports, so a component reaches for them inside ` ``` `list` is typed against every array path in the schema, so the path autocompletes to arrays only, and each entry's type narrows to the element shape. ## Why key by `row.key` `row.key` is an allocated identity token, not the index. It is minted once for an element and travels with it through `insert`, `remove`, `move`, and `swap`, staying distinct even when two elements hold identical values. Keying a `v-for` by the index instead ties each row to a slot, so a reorder reshuffles which DOM node and component instance render which element; a half-typed input can jump to the wrong row. Keying by `row.key` ties each row to its element, so the row a user is editing stays put when the list around it moves. The same token is on every FieldState as [`field.key`](/docs/reading-the-form/fields), reachable through `form.fields('roster.0').key` when you need it outside an iteration. ## Each entry is a live FieldState The entries are the same field states `form.fields` exposes, so every read stays live as the user interacts. A row carries the full surface: `row.value`, `row.errors`, `row.showErrors`, `row.firstError`, `row.touched`, and the rest. ```vue ``` Binding still flows through `form.register` with the element path; `list` supplies the key and the per-row reads, and the array index supplies the register path. ## `form.fields` stays the aggregate `form.fields('roster')` remains the single aggregated container for the whole array: one rolled-up FieldState whose `errors`, `valid`, and `touched` summarize every element at once. That is the read for an array-level message (`z.array(...).min(1)` lands there). `list` is the complementary per-element view. Reach for the aggregate when you want one verdict for the array, and for `list` when you want a row each. ## Read-only by design The returned array is frozen. Identity is bookkept by the mutation helpers, so shape changes go through them rather than the view: - [`append`](/docs/writing-and-mutating/field-arrays) / `prepend` / `insert` to add a row. - `remove` to drop one, `move` / `swap` to reorder. - `replace` to overwrite a slot with a fresh element. Each helper replays its exact change onto the identity tokens, which is what lets `row.key` stay true across the mutation. ## `record` is the record counterpart `list` is for arrays. For a record, whose entries are keyed rather than ordered, reach for [`record`](/docs/reading-the-form/record): it hands back a keyed object, one FieldState per entry under the entry's own key. The two split cleanly by path type, and each rejects the other at compile time. ## Where to next - [`record`](/docs/reading-the-form/record): the record counterpart, one FieldState per entry keyed by the record's own key. - [Field-array mutations](/docs/writing-and-mutating/field-arrays): the seven helpers that add, remove, and reorder elements. - [`fields`](/docs/reading-the-form/fields): the per-leaf FieldState and the `key` every entry carries. - [The `v-register` directive](/docs/binding-inputs/v-register): the binding each row's input flows through. - [`errors`](/docs/reading-the-form/errors): the per-path errors behind `row.firstError`. _Source: https://attaform.dev/docs/reading-the-form/list_ --- # `record` > One record, one FieldState per entry, keyed by the entry's own key. Iterate it with `v-for="(field, key) in form.record(path)"` and bind `:key="key"`. `form.record(path)` is the iteration view over a record. Where [`list`](/docs/reading-the-form/list) hands back an ordered array for an array path, `record` hands back a keyed object for a record path: one [`FieldState`](/docs/reading-the-form/fields) per entry, under the entry's own key. Reach for it whenever the keys are the data, set at run time rather than declared in the schema. ## Iterating a record Declare the record on your schema, then iterate `form.record` by its key. The keys come from the form, so you render whatever entries exist without keeping a parallel list of your own: ```vue ``` `record` is typed against every record path in the schema (a `z.record(...)`, not a fixed-shape `z.object({ ... })`), so the path autocompletes to records only, and each entry's type narrows to the record's value shape. ## Each entry is a live FieldState Each value in the returned object is the same field state [`fields`](/docs/reading-the-form/fields) exposes, so every read stays live as the user types. An entry carries the full surface: `field.value`, `field.errors`, `field.showErrors`, `field.firstError`, `field.touched`, and the rest. Binding still flows through [`form.register`](/docs/binding-inputs/v-register) with the entry path, which the key supplies. ## Growing and shrinking The returned object is frozen, a read-only view. A record carries its own keys, so you grow or shrink it through [`setValue`](/docs/writing-and-mutating/set-value) at an entry path. Write a key that isn't there yet and a new entry joins the view: ```ts form.setValue('scoresByTeam.west', 0) ``` The existing entries keep their field states and their component instances; only the new row mounts. To drop an entry, write the record back without that key. ## `form.fields` stays the aggregate `form.fields('scoresByTeam')` remains the single aggregated container for the whole record: one rolled-up FieldState whose `errors`, `valid`, and `touched` summarize every entry at once. Reach for the aggregate when you want one verdict for the record, and for `record` when you want an entry each. ## The root record, `form.record()` When the schema root is itself a `z.record` (a [dictionary form](/docs/schemas/dictionary-forms)), call `form.record()` with no argument for the root entry view. It hands back the same keyed object of field states, one per entry, for the form's top-level map: ```vue ``` The no-argument form is available only on a record root; on a fixed-shape object root it is a compile error, the same way the path form requires a record path. See [Dictionary forms](/docs/schemas/dictionary-forms) for the whole-form story. ## `list` is the array counterpart For an array, reach for [`list`](/docs/reading-the-form/list), which returns an ordered FieldState array keyed by an allocated identity token that survives reorders. `record` and `list` split cleanly by path type: a record reads through `record`, an array through `list`, and each rejects the other at compile time. ## Where to next - [`list`](/docs/reading-the-form/list): the array counterpart, one FieldState per element with reorder-stable keys. - [Dictionary forms](/docs/schemas/dictionary-forms): a `z.record` schema as the form root, iterated with the no-argument `form.record()`. - [Records & maps](/docs/schemas/records): declaring a `z.record(...)` schema and binding its entries. - [`fields`](/docs/reading-the-form/fields): the per-leaf FieldState every entry carries. - [`setValue`](/docs/writing-and-mutating/set-value): how an entry joins or leaves the record. _Source: https://attaform.dev/docs/reading-the-form/record_ --- # `errors` > A reactive Proxy keyed by schema paths. Every leaf carries its current error list, container reads aggregate everything underneath, and the whole tree re-renders the moment validation re-runs. `form.errors` is the raw validation surface, paired one-to-one with `form.values` and `form.fields`. The demo seeds invalid values up front so the panels light up on mount, then updates live as you edit each field. The container panel shows the live `profile` sub-tree, the form-level panel shows the root-only errors via `form.meta.ownErrors`, and the whole-form panel shows the full sparse tree. ## Leaf reads ```ts const schema = z.object({ email: z.email('Enter a valid email'), name: z.string().min(1, 'Name is required'), }) const form = useForm({ schema }) form.errors.email // readonly ValidationError[] form.errors.email[0]?.message // 'Enter a valid email' | undefined form.errors.email.length // 0 when valid ``` A static object leaf always returns an array, `readonly ValidationError[]`, empty when valid. Reads past a dynamic boundary carry `| undefined`, because the node there may be absent: a numeric array index (`form.errors.todos[3]?.title`), a record key (`form.errors.byId.missing`), or a field that only exists on an inactive discriminated-union variant. That `| undefined` is honest. Dot and index access is pure navigation, so a key the schema doesn't declare and the data doesn't hold reads `undefined`, never a stand-in proxy, and a truthy check agrees with the runtime. The one exception is a server error parked at a non-schema key: the error stores count as holding it, so it stays reachable and its message lands on the [`''` container-self sentinel](#the-sentinel-container-self-errors) (`form.errors.ghost['']`). The first error's `.message` is what most templates render: ```vue ``` For display ergonomics, gating by [`getDisplayState`](/docs/validation/showing-errors) and pulling the first error in one shot, reach for [`form.fields.email.firstError`](/docs/reading-the-form/fields) paired with `form.fields.email.showErrors`. The errors Proxy is the raw aggregate; the fields Proxy is the same data with display gating and `firstError` sugar layered on. ## Container reads `form.errors` is a drillable Proxy: dot-access descends into containers (returning a sub-Proxy you can keep drilling), and the call form returns a flat aggregate at any path. The two surfaces serve different jobs: ```ts form.errors.profile // sub-Proxy: { '': [...refines], bio: [...], ... } form.errors('profile') // flat array: every error inside profile + container-self form.errors() // flat array: every error in the form form.errors([]) // flat array: same as errors(), the whole-form aggregate form.meta.ownErrors // flat array: root-level errors only (root .refine(), form-level setErrors) ``` `form.errors()` is the cheapest "is anything wrong?" check (`form.errors().length === 0` when the form is valid). `form.errors(path)` is uniform at every depth, so `form.errors([])` is the same whole-form aggregate as `form.errors()`, the root included. For the root-level errors alone (a root `.refine()`, an imperatively-set form error) with no field errors mixed in, read `form.meta.ownErrors`, or its first via `form.meta.firstOwnError`. For aggregated counts and submission-state bits, see [`form.meta`](/docs/reading-the-form/meta). When you serialize the dot-form (`JSON.stringify(form.errors)` or `{{ form.errors }}` in a template), the Proxy materializes the live sparse tree, so you can dump the whole error state for debugging without losing structure; global errors sit under the `'[]'` key in that dump, kept distinct from every field. ### The `''` sentinel: container-self errors A cross-field `.refine()` lives on a container, not a leaf: ```ts const schema = z.object({ profile: z .object({ bio: z.string().max(50), handle: z.string(), }) .refine((p) => p.bio.includes(p.handle), 'Bio must mention your handle'), }) ``` The refine's error path is `['profile']`, the container itself. To keep `form.errors.profile` readable alongside leaf errors at `['profile', 'bio']` and `['profile', 'handle']`, container-self errors land in the materialized tree under the `''` sentinel slot: ```ts form.errors.profile[''] // refine errors on profile (and any other container-self entries) form.errors.profile.bio // leaf errors on bio ``` `JSON.stringify(form.errors.profile)` materializes as `{ '': [refineError], bio: [maxError], handle: [...] }`. Both the refine and the descendant leaves coexist; nothing clobbers anything. The same convention reaches all the way down: a refine on `profile.address` lands at `form.errors.profile.address['']`. The call form is the flat alternative: `form.errors('profile')` returns one `ValidationError[]` containing the refine PLUS every descendant leaf error in declaration order, no structure. Reach for the structural tree when you want to render per-field; reach for the call form when you want "anything wrong under this container?". The sentinel is a nested-container convention: it appears one level deep or more. The root has no `''` self-slot, because at the root `''` is just an ordinary field key. `form.errors['']` reads a literal field named `''` (a rare but valid schema key), while global, form-level errors (a root `.refine()`, a path-less `setErrors` call) live at the structurally-distinct root path and read through `form.meta.ownErrors`. In a `JSON.stringify(form.errors)` dump they appear under the `'[]'` key, never under `''`, so the two never collide. If your schema legitimately declares a field literally named `''` at a nested container (an exceptionally rare choice), that leaf's own errors and the container-self errors share the sentinel slot, and both arrays concatenate into a single read. At the root no such collision exists: `''` is a plain field there, kept entirely separate from the global bucket at `form.meta.ownErrors`. ## Setting errors imperatively Server-side errors land in the same reactive store as Zod errors. Hand `form.setErrors` a `ValidationError[]` and each entry lands at its `path`: ```ts form.setErrors([ { path: ['email'], message: 'Already taken' }, { path: ['profile', 'handle'], message: 'Reserved' }, ]) ``` `form.errors.email` and `form.errors.profile.handle` update immediately, and any `form.fields..firstError` / `form.fields..showErrors` reads update with them. The render surface is identical whether the error came from Zod or your API. See [Server-side errors](/docs/submitting/server-side-errors) for the full `handleSubmit` flow: scoped and functional updates, clearing, and the opaque `data` payload slot. ## Where to next - [The form](/docs/reading-the-form/the-form): every other reactive read. - [`values`](/docs/reading-the-form/values): the read companion to errors. - [`fields`](/docs/reading-the-form/fields): per-leaf state, including the gated `firstError` / `showErrors` pairing. - [`meta`](/docs/reading-the-form/meta): the form-level aggregates (`errorCount`, `valid`, `submitting`, etc.). - [When validation runs](/docs/validation/when-validation-runs): the moment errors appear. - [Server-side errors](/docs/submitting/server-side-errors): `setErrors` and `clearErrors` in full. _Source: https://attaform.dev/docs/reading-the-form/errors_ --- # `meta` > Form-level state in one place: every FieldState bit rolled up across paths, plus the seven form-only reads for the submit cycle and wizard departures. Submit the demo without changing the simulate-failure toggle to watch `submitting` flip true mid-await, `submissionAttempts` increment, and `submitted` flip true once the callback succeeds. Flip the toggle and submit again: `submissionAttempts` still increments and `submitError` populates with the rejected callback's message, but `submitted` stays false because the callback never resolved. The [Form-only properties](#form-only-properties) section below names every bit; the inherited FieldState aggregations [link forward to the fields page](/docs/reading-the-form/fields). ## Two halves `form.meta` extends `FieldState` with seven form-only properties. That means `meta` has 38 reads total: - 31 properties inherited from FieldState, aggregated across every leaf in the form. - 7 form-only properties that describe the submit cycle and the wizard-departure counter. The inherited bits are documented once on the [`fields` page](/docs/reading-the-form/fields): same property names, same types, same reactivity. The only difference is the aggregation: ```ts form.fields.email.dirty // this one field form.meta.dirty // any field in the form form.meta.errors // every error across every path (the whole-form aggregate) form.meta.ownErrors // the root [] bucket alone: the form's own top-level errors form.meta.value // the full form values object ``` `meta.errors` rolls up every path. `meta.ownErrors` is the exception to the aggregation rule: it reads the root's own bucket only, the form-level errors a root `.refine()` or a path-less `setErrors` pins at `[]`, with no field errors folded in. It is the banner accessor. ## Form-only properties These seven reads exist only on `meta`, not on individual FieldStates. | Property | Type | Meaning | | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `submitting` | `boolean` | `true` while a `handleSubmit`-produced handler is running. Covers both the validation phase and the async callback. | | `submissionAttempts` | `number` | How many times the handler has been invoked (pass or fail). Useful for "show errors after first submit" UX. | | `departAttempts` | `number` | How many times wizard navigation has actually departed this form. Bumps on real departures only (no-op back / same-key goTo / blocked next stay put). | | `submitError` | `unknown` | The error from the most recent callback rejection. `null` on success and at the start of each new attempt. | | `errorCount` | `number` | Scalar mirror of `errors.length`. Read it from templates and `watch()` without indexing the array. | | `submitted` | `boolean` | `true` once a `handleSubmit` callback resolves without throwing and without leaving errors set. Failed submits, including a `setErrors(...); return`, leave it `false`. Zeroed by `form.reset()`. | | `instanceId` | `string` | Per-`useForm()`-call identity, stable for the lifetime of one call. New on every fresh mount. | ## Templates The classic submit-button pattern reads two bits: ```vue ``` The "show errors after first submit attempt" pattern reads the counter so failed attempts count: ```vue

{{ form.meta.errorCount }} field(s) need attention.

``` The form-level error banner reads `firstOwnError`, the form's own top-level error (a root `.refine()`, a path-less `setErrors`), with no field error mixed in: ```vue

{{ form.meta.firstOwnError.message }}

``` The demo pairs two passwords with a root `.refine()`. Submit a mismatch and the refine's error lands at the form root, where `form.meta.firstOwnError` reads it for the banner and `form.meta.ownErrors` holds the whole root bucket: The "post-success confirmation" pattern reads `submitted` instead, so the banner only renders after the callback actually succeeded. A callback that hands a server rejection to `setErrors` and returns counts as a failed submit, so the banner stays hidden: ```vue

All saved.

``` The form-summary pattern reads three: ```vue

{{ form.meta.dirty ? 'Unsaved changes' : 'No changes' }} · {{ form.meta.valid ? 'Ready to submit' : `${form.meta.errorCount} error(s)` }} · Submitted {{ form.meta.submissionAttempts }} time(s)

``` ## submitError lifecycle `submitError` mirrors what the callback threw or rejected with, coerced to a real `Error` (a non-`Error` throw keeps its origin on `.cause`). `handleSubmit` catches the throw rather than re-raising it, so a rejected `onSubmit` never escapes as an unhandled rejection. - `null` at form mount, between attempts, and on success. - Set to the thrown / rejected value on callback failure. - Cleared at the start of the next submit attempt. A thrown callback surfaces twice. `submitError` is the raw-`Error` inspection channel above; the same failure is also piped into the error layer as a `ValidationError`. A bare `throw new Error(...)` lands form-level (the root `[]` bucket), where [`form.meta.firstOwnError`](#templates) and `form.meta.ownErrors` read it for a banner; throw a `{ path, message, code? }` (or an array of them) to land it on a specific field, where it joins `form.errors.` under your own `code`. Either way it also rolls into `form.meta.errors`. This is separate from the `setErrors` path, which never touches `submitError`. Reach for it when an inline failure banner needs to react to submit errors without your own `try { await onSubmit() }` wrapper: ```vue

Submission failed: {{ form.meta.submitError instanceof Error ? form.meta.submitError.message : String(form.meta.submitError) }}

``` ## departAttempts `departAttempts` counts how many times `wizard.next`, `wizard.back`, or `wizard.goTo` has actually left this form's step. The counter bumps on real departures only: - `back()` from the first step is a no-op and leaves it alone. - `goTo(currentKey)` (same-key jump) leaves it alone. - `next()` blocked by failed activation leaves it alone. The counter is a pure read; Attaform's default `getDisplayState` heuristic runs off `submissionAttempts` instead. Reach for `departAttempts` when an analytics event, a prior-step badge, or a layered error-reveal predicate wants the "user visited and left" signal: ```ts watch( () => form.meta.departAttempts, (count) => { if (count === 1) analytics.track('step_first_departure', { form: form.key }) } ) ``` Cleared by `form.reset()` alongside the submission counters. ## instanceId `instanceId` distinguishes two mounts of the same shared form. Two `useForm({ key: 'signup' })` calls return the same FormStore (so writes in one reflect in the other), but `form.meta.instanceId` differs. Useful when devtools, telemetry, or e2e selectors need to disambiguate which mount triggered an event. ```vue
``` Treat as identity, not state: don't parse it, don't compare ordinally, don't persist. ## Where to next - [`fields`](/docs/reading-the-form/fields): the per-leaf FieldState, including every property `meta` inherits. - [`handleSubmit`](/docs/submitting/handle-submit): the dispatch surface that drives `submitting`, `submissionAttempts`, and `submitError`. - [The form](/docs/reading-the-form/the-form): the full reactive surface that surrounds `meta`. _Source: https://attaform.dev/docs/reading-the-form/meta_ --- # `toRef` > Get a `Readonly>` at any schema path, for the rare case a consumer surface expects a Vue ref instead of the values Proxy. `form.toRef(path)` returns a `Readonly>` whose `.value` tracks storage at `path`. For normal reads (templates, computeds, conditional rendering), `form.values.` is the right call. `toRef` is for ref-shaped interop only. ```ts const schema = z.object({ profile: z.object({ email: z.email() }), todos: z.array(z.object({ title: z.string() })), }) const form = useForm({ schema }) ``` ## When to use it External composables, watchers, and DevTools probes sometimes expect a `Ref` rather than a Proxy property: ```ts const emailRef = form.toRef('profile.email') // Readonly> // Hand off to any third-party composable whose signature expects a Ref useThirdPartyRefConsumer(emailRef) // Watch a single path explicitly watch(emailRef, (next) => { /* respond to email changes */ }) ``` ## Two call forms `toRef` accepts either a dotted path or a segment tuple. Both resolve to the same leaf with the same inferred type: ```ts form.toRef('profile.email') form.toRef(['profile', 'email']) ``` The dotted form is shorter and idiomatic. The tuple form is the escape hatch for keys that contain a literal `.` (a dotted form can't disambiguate `{ 'a.b': … }` from nested `{ a: { b: … } }`). Pick whichever matches the surrounding code's grammar. ## Read-only by contract `toRef` returns `Readonly>`. Writes go through the same paths every other consumer uses: ```ts form.setValue('profile.email', 'a@b.c') // imperative write form.register('profile.email') // bound writes via v-register form.append('todos', { title: '' }) // structural writes ``` Attaform tracks dirty, touched, and validation state through those write paths. Assigning to `.value` directly throws; `toRef` is a read handle, not a backdoor. ## Reactivity contract `toRef` returns a `ComputedRef`-equivalent shape: reads inside reactive scopes track the path, and consumers re-run when storage at `path` changes. Two refs to the same path share reactivity; they don't double-subscribe. ```ts const refA = form.toRef('profile.email') const refB = form.toRef('profile.email') // refA.value === refB.value, always. // One storage write triggers both refs' subscribers. ``` The same path-precise reactivity Vue offers on `form.values.profile.email`, just wrapped in a `Ref`. ## Where to next - [`values`](/docs/reading-the-form/values): the Proxy you should reach for first, before `toRef`. - [`fields`](/docs/reading-the-form/fields): the per-leaf reactive surface for state bits, not just values. - [The form](/docs/reading-the-form/the-form): every other reactive read on the return shape. _Source: https://attaform.dev/docs/reading-the-form/to-ref_ --- # Type safety > Wide while the user is typing, tight the moment the schema validates. Two surfaces, one promise: what you see is what is actually there. A form library has to tell the truth about what is in the form. Mid-typing, that is whatever the user has put there so far: an incomplete email, an undecided radio pick, a partially-filled subtree rehydrated from yesterday's session. Post-submit, it is the Zod schema's parsed output: every refinement honored, every literal narrowed, every transform applied. Attaform's types model both states. `form.values`, `defaultValues`, and `form.setValue` use the **in-flight shape**, wide enough to hold whatever the form is actually carrying. `handleSubmit((values) => ...)` hands you the **validated shape**: narrow, exact, what the schema promised. ## The gateway example: email Take the simplest schema: ```ts const schema = z.object({ email: z.email('Enter a valid email'), }) const form = useForm({ schema }) ``` What is the type of `form.values.email`? `string`. Not a branded `Email`, not `string & { __brand: 'email' }`. Plain `string`. The reason is that during form completion, `form.values.email` legitimately holds `"a"`, then `"andy"`, then `"andy@"`. None of those satisfy `z.email()`, and the schema knows that. That is why [`form.errors.email`](/docs/reading-the-form/errors) populates while the user is mid-typing. If `form.values.email` were typed as a branded `Email`, the type would lie about what is actually there. The runtime value would be `"andy@"`; the static type would claim it is a valid email. The compiler cannot help you when it has been told a fiction. So the type follows reality: while the user is typing, `email` is whatever string they have typed. After the schema parses successfully inside `handleSubmit`, it is whatever the schema promises. ## Discriminators do the same thing ```ts const schema = z.object({ transport: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('boat'), hullLengthM: z.number() }), z.object({ kind: z.literal('truck'), payloadKg: z.number() }), ]), }) const form = useForm({ schema }) form.values.transport.kind // string (not 'boat' | 'truck') ``` Same reasoning, one layer up. The user may not have picked a variant yet. They may be mid-keystroke in an input wired to the discriminator. They may be rehydrating a draft that was saved with `kind: ''`. Typing `transport.kind` as `'boat' | 'truck'` would lie about every one of those cases. The literal narrowing engages where it earns its keep: inside `handleSubmit`, after the schema confirms the discriminator is one of the variants. There, `values.transport.kind` is `'boat' | 'truck'`, and TypeScript narrows `values.transport` to the matching variant. ## `defaultValues` works the same way ```ts const form = useForm({ schema, defaultValues: { email: '', // empty string is fine, even though z.email() will reject it transport: { kind: 'boat', hullLengthM: 0 }, // any starting state, even invalid }, }) ``` Rehydration is the killer use case. Yesterday's user opened the form, picked a variant, typed half an email, then closed the tab. Today's `defaultValues` (re-read from `localStorage`, `sessionStorage`, or your server) needs to faithfully restore that half-filled state. If `defaultValues.email` required a value that already passed `z.email()`, you could not rehydrate the "still typing" case at all. You would be forced to invent a valid email the user never typed, or wipe the field entirely and lose their work. Wide in-flight types mean you can store, hydrate, autosave, and resume from any intermediate state. The schema is still in charge of what counts as valid; the types just acknowledge that the form's job is to **get there**, not to always be there. ## Where the types tighten `handleSubmit` is the boundary: ```ts const onSubmit = form.handleSubmit((values) => { values.email // string (validated, passed z.email()) values.transport.kind // 'boat' | 'truck' if (values.transport.kind === 'boat') { values.transport.hullLengthM // narrowed to number } else { values.transport.payloadKg // narrowed to number } }) ``` The argument to your success callback is `z.infer`, Zod's parsed output. Every literal is preserved, every refinement is honored, every transform is applied. If the schema did not parse cleanly, the success callback never fires; the error callback runs instead with the in-flight values for you to inspect. That is the contract: while the form is being filled in, the types help you handle every shape it might be in. The moment validation succeeds, the types snap to what the schema actually guarantees. ## Why not narrow earlier? A library that narrowed `form.values.email` to a branded `Email` (or `form.values.transport.kind` to `'boat' | 'truck'`) before validation would have to do one of two things, both bad: - **Refuse to type partial state.** Empty strings, half-typed emails, undecided discriminators are all invalid against the schema. Either `form.values` could not represent them (the form cannot work) or the types would lie (the compiler cannot help). - **Force consumers to cast.** Every read becomes `as string`, every assignment becomes a fight with the type checker. The schema's value as a source of truth evaporates because you are routing around it on every line. Wide in-flight types skip both failure modes. The schema still owns what counts as valid; the form just acknowledges that getting to valid is the user's job, not the type system's. ## Where to next - [`values`](/docs/reading-the-form/values): the in-flight read surface this page is built on. - [`handleSubmit`](/docs/submitting/handle-submit): the boundary where types tighten. - [Discriminated unions](/docs/schemas/discriminated-unions): the schema feature that benefits most from the tight side of this contract. _Source: https://attaform.dev/docs/reading-the-form/type-safety_ --- # The `v-register` directive > One directive binds a native input to a schema path. The `` stays native; Attaform sits at the directive layer. Click the input, type a few characters, blur, refocus. The four `form.fields.email.*` bits in the table below flip with each interaction. The directive surfaces every signal the schema-aware layer needs without you wiring a single event listener; the [What it does](#what-it-does) section unpacks the four pieces of plumbing. ## What it does Bind any native input to a schema path: ```vue ``` The directive runs four pieces of plumbing for you: 1. **Reads** the current value from `form.values.` and writes it into the DOM input on initial render and on every reactive update. 2. **Writes** back to `form.values.` on every `input` event (or `change` / `blur` with modifiers). 3. **Coerces** the DOM string to the schema's leaf type: `type="number"` inputs land in storage as a number, checkboxes as a boolean, radio groups pick the option `value`. 4. **Tracks** field state (`touched`, `focused`, `blurred`, `blank`) and surfaces it through `form.fields.`. ## Let `v-register` own the value Because the directive already reads and writes the field's value, a second binding for the same value is redundant, and the two can end up fighting over the DOM. Leave off `:value` or `v-model` on a text input or ` ``` The one `:value` that stays is an identity rather than state: the value a radio or an `