fields

A reactive Proxy keyed by schema paths. Every leaf surfaces a 31-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 (31 properties)

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 any leaf under profile is dirty, 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 31-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; survives reset cycles.
interactedbooleantrue after the user's first value edit through v-register; survives reset cycles.
blurredAfterInteractionbooleantrue after the first blur that follows an edit (edited, then left); drives the default error-reveal gate.
connectedbooleantrue while at least one element is bound via v-register.
blankbooleantrue while the leaf reads as empty per the blank predicate.
updatedAtstring | nullISO timestamp of the last write; null until first write.

Value reads

The data sitting at this path right now, and what it was at hydration.

PropertyTypeMeaning
valueTCurrent leaf value (same as form.values.<path>).
originalTHydration-time value; dirty flips when value !== original.

Validation reads

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

PropertyTypeMeaning
errorsreadonly ValidationError[]Every error at this path, schema-declaration order.
firstErrorValidationError | undefinedSugar for errors[0].
validbooleanerrors.length === 0 && !validating.
validatingbooleantrue while a per-field validation run is in flight.
displayState'idle' | 'pending' | 'error' | 'success'The single display-state verdict, resolved by getDisplayState.
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.

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/zod'

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.