.md

fields

A reactive Proxy keyed by schema paths. Every leaf surfaces a 37-property FieldState: state bits, value reads, validation reads, DOM handles, and schema metadata, all in one snapshot the form keeps in sync as users interact.

Category
Return property
Type
FieldStateMap<ReadShape<Schema>>
Reactive
Yes
Read shape per leaf
FieldState

form.fields.<path> is the per-leaf companion to form.values.<path>. Where values answers "what's at this path?", fields answers everything else: has the user touched it, is it focused, is it dirty, what errors does it carry, should we be showing them yet, which DOM element is bound. Type into the email input, blur it, refocus, submit. Every cell in the demo's panel updates live, and the schema metadata at the bottom comes from withMeta(...) on the schema itself.

Field State Demo Open in playground

form.fields.email

State bits

pristinetruedirtyfalse
focusedfalseblurredtrue
touchedfalseblankfalse
connectedtrueupdatedAt…

Value reads

value""
original""

Validation reads

validfalsevalidatingfalse
showErrorsfalseerrors.length1

Schema metadata + identity

label"Email address"
placeholder"you@example.com"
description"We only use it to send you a confirmation."
path["email"]

Drillable Proxy

form.fields mirrors form.values' shape: a Proxy keyed by schema paths. Reach any leaf:

const schema = z.object({
  profile: z.object({
    name: z.string(),
    email: z.email(),
  }),
  age: z.number(),
})

const form = useForm({ schema })

form.fields.profile.name.touched
form.fields.profile.email.firstError?.message
form.fields.age.dirty

Container paths (form.fields.profile) keep drilling: a container is a navigation node, not a FieldState, so it carries no dirty or errors of its own on the dot surface. When you want a container's rolled-up state, call it. form.fields('profile') returns a FieldState whose dirty is true if anything under profile differs from its original, a removed field included, and whose errors flatten every descendant error.

Absence reads undefined

Dot and index access is pure navigation, so a node that isn't there right now reads undefined, never a phantom you can keep chaining off. Three hops are dynamic, where the live shape decides whether the node exists:

  • an out-of-bounds array index, form.fields.tags[99],
  • a record key the data doesn't hold, form.fields.byId.missing,
  • a discriminated-union key whose variant isn't active, form.fields.cargo.permitNumber while the general variant is live.

The types say exactly what the runtime does. Each dynamic hop is FieldState<T> | undefined, so TypeScript asks for a ?. right where a read might come back empty, and a truthy check never lies:

form.fields.tags[0]?.value
form.fields.cargo.permitNumber?.firstError?.message

A declared optional field is always present, because the schema names it: form.fields.profile.nickname.value is a real FieldState whose value is undefined, not an absent node. Register it, read it, reveal it, no guard required.

A whole-array binding reads through the call-form

Point one register at an <input type="file" multiple> and it writes the entire File[] through a single array path. The dot surface descends into elements, so that array field's own busy and transform state reads through the call-form:

form.fields('links').busy
form.fields('links').transformError

What FieldState carries

Each leaf exposes a 37-property FieldState object. The properties fall into five jobs:

State bits

The reactive lifecycle of a field: how it got here, what it's doing now.

PropertyTypeMeaning
pristinebooleantrue until the value diverges from the original.
dirtybooleanInverse of pristine.
focusedboolean | nulltrue while the element is focused; null while disconnected.
blurredboolean | nullInverse of focused when connected; null while disconnected.
touchedbooleantrue after the first blur. Stays true through a value round trip; reset() and resetField() clear it.
interactedbooleantrue after the user's first value edit through v-register. Stays true through a value round trip (typed, then deleted back); reset() and resetField() clear it.
blurredAfterInteractionbooleantrue after the first blur that follows an edit (edited, then left); drives the default error-reveal gate. reset() and resetField() clear it.
connectedbooleantrue while at least one element is bound via v-register.
blankbooleantrue while the leaf reads as empty per the blank predicate.
disabledbooleantrue while the form is frozen by useForm({ disabled }). Form-level: every field of a disabled form reads it.
updatedAtstring | nullISO timestamp of the last write at this path or under it, counting the form's creation. A removal is a write too, so it never moves backwards. null only for a path never written or no longer present.

At a container, the call form rolls these bits up across the subtree. focused, blurred, connected and blank describe the fields there right now. The interaction history remembers the ones that are gone: touched, interacted and blurredAfterInteraction stay true at every ancestor after the field that set them is removed, until reset() clears them (or resetField(path), for its own subtree). So when the user edits a row and then removes it, an error the removal causes at the array reveals at once, with no fresh blur:

const schema = z.object({
  rows: z.array(z.object({ amount: z.number() })).min(2),
})

const form = useForm({ schema, defaultValues: { rows: [{ amount: 1 }, { amount: 2 }] } })

// The user edits the second row's amount and tabs away, then removes the row:
form.remove('rows', 1)

form.fields('rows').blurredAfterInteraction // true, kept from the removed row
form.fields('rows').showErrors // true: the removal broke min(2)
form.meta.updatedAt // the removal's timestamp

A survivor keeps its own updatedAt when a removal or a reorder moves it to a new index, and a write that changes nothing stamps nothing. Every write that changes a value reads later than the one before it: two writes in the same millisecond, or a write on a device whose clock runs behind the server that rendered the page, take one millisecond past the latest stamp the form holds.

Value reads

The data sitting at this path right now, and what it started as.

PropertyTypeMeaning
valueTCurrent value (same as form.values.<path>).
originalTThe value the path started as: the defaults, re-seated by reset() and by async defaults. dirty flips when value differs from it. undefined for a field or row added since.

At a container, original is the subtree the container started as, and each row reads its own. A row keeps its identity through remove, swap, move and insert, so its original is still the row it was, at whatever index it sits now. Every read shares one frozen value, and writing it back with setValue stores a copy, so the baseline stays as it was:

const schema = z.object({
  rows: z.array(z.object({ n: z.string() })),
})

const form = useForm({ schema, defaultValues: { rows: [{ n: 'a' }, { n: 'b' }, { n: 'c' }] } })

form.remove('rows', 0)

form.fields('rows').original // [{ n: 'a' }, { n: 'b' }, { n: 'c' }]
form.fields('rows.0').original // { n: 'b' }, the row now at index 0
form.fields('rows.0.n').original // 'b'

form.append('rows', { n: 'd' })
form.fields('rows.2').original // undefined: added since

Validation reads

The error surface at this path: raw, ergonomic, and gated.

PropertyTypeMeaning
errorsreadonly ValidationError[]Every error in this path's subtree (this node plus descendants), schema-declaration order.
firstErrorValidationError | undefinedSugar for errors[0].
ownErrorsreadonly ValidationError[]Every error at this path's own bucket only, excluding descendants. On a leaf, identical to errors.
firstOwnErrorValidationError | undefinedSugar for ownErrors[0].
validbooleanerrors.length === 0 && !validating.
validatingbooleantrue while a per-field validation run is in flight.
transformingbooleantrue while an async register transform at this path has not yet committed. Containers roll it up.
busybooleantransforming || validating. The signal to bind a busy indicator to, since it covers both kinds of work.
transformErrorError | nullThe Error from the most recent async transform that rejected here. Leaf-only, and separate from errors: a transform that fails is a normalization failure, not a validation verdict.
displayState'idle' | 'pending' | 'error' | 'success'The single display-state verdict. See Showing errors.
showErrorsbooleandisplayState === 'error'. The display-time error gate.
showPendingbooleandisplayState === 'pending'. A check has run long enough to earn a spinner.
showSuccessbooleandisplayState === 'success'. The field has passed.
showIdlebooleandisplayState === 'idle'. Nothing to surface yet.

Two error scopes sit side by side. errors and firstError roll up the subtree: a container's errors is non-empty whenever any descendant is invalid. ownErrors and firstOwnError read the own bucket at this exact path, so a container's own cross-field .refine() surfaces on its own without dragging in child errors. On a leaf the two scopes coincide (a leaf has no descendants), and ownErrors is the very same array as errors.

DOM reads

Direct handles to the bound elements for imperative work: focus(), scrollIntoView(), measure positions, attach observers, anything Attaform deliberately doesn't wrap behind helpers.

PropertyTypeMeaning
elementHTMLElement | nullFirst bound element by registration order.
elementsreadonly HTMLElement[]Every element bound to this path.
form.fields.email.element?.focus()
form.fields.email.element?.scrollIntoView({ block: 'center' })

Schema metadata + identity

Schema-registered presentational hints, the path that produced this FieldState, the stable DOM ids for wiring labels and assistive-tech references, and the element identity key for iterating array elements.

PropertyTypeMeaning
labelstringRegistered label, or a humanized fallback from the path's last segment.
descriptionstring | undefinedRegistered description; falls back to schema.describe('...') when no override.
placeholderstring | undefinedRegistered placeholder hint.
metaReadonly<FieldMetaPayload>The full registered payload; escape hatch for consumer-augmented keys.
pathreadonly (string | number)[]The path tuple that produced this FieldState.
idstringStable, SSR-safe DOM id for this field, unique across every mount on the page.
aria{ errorId, descriptionId }Satellite ids derived from id for wiring error and description elements.
keystringAllocated identity token while the field is an array element, empty otherwise. Follows the element across reorders; the :key for form.list.

Register schema metadata with withMeta (works on Zod 3 and Zod 4) or the native schema.register(fieldMeta, {...}) chain (Zod 4):

import { withMeta } from 'attaform'

const schema = z.object({
  email: withMeta(z.email(), {
    label: 'Email address',
    placeholder: 'you@example.com',
  }),
})

Reading FieldState in templates

The display-ergonomics pairing of firstError plus showErrors is the per-field rendering pattern:

<input v-register="form.register('email')" />
<p v-if="form.fields.email.showErrors">{{ form.fields.email.firstError?.message }}</p>

showErrors is the gate; firstError is the message. Read them together at every error site.

Where to next

  • form.list: iterate an array as one FieldState per element, keyed by key so a v-for survives reorders.
  • values: the value half of every FieldState, lifted to a form-wide Proxy.
  • errors: the per-path errors Proxy, the raw array behind firstError.
  • meta: the form-level aggregation, every FieldState property rolled up, plus 7 form-only reads.
  • The v-register directive: the binding that drives touched, focused, blurred, and connected.
  • Display state and showing errors: the predicate behind displayState.
  • The blank field-state bit: the lifecycle behind the blank cell.
  • The form: every other reactive read on form itself.