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.
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.permitNumberwhile thegeneralvariant 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.
| Property | Type | Meaning |
|---|---|---|
pristine | boolean | true until the value diverges from the original. |
dirty | boolean | Inverse of pristine. |
focused | boolean | null | true while the element is focused; null while disconnected. |
blurred | boolean | null | Inverse of focused when connected; null while disconnected. |
touched | boolean | true after the first blur; survives reset cycles. |
interacted | boolean | true after the user's first value edit through v-register; survives reset cycles. |
blurredAfterInteraction | boolean | true after the first blur that follows an edit (edited, then left); drives the default error-reveal gate. |
connected | boolean | true while at least one element is bound via v-register. |
blank | boolean | true while the leaf reads as empty per the blank predicate. |
updatedAt | string | null | ISO 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.
| Property | Type | Meaning |
|---|---|---|
value | T | Current leaf value (same as form.values.<path>). |
original | T | Hydration-time value; dirty flips when value !== original. |
Validation reads
The error surface at this path: raw, ergonomic, and gated.
| Property | Type | Meaning |
|---|---|---|
errors | readonly ValidationError[] | Every error at this path, schema-declaration order. |
firstError | ValidationError | undefined | Sugar for errors[0]. |
valid | boolean | errors.length === 0 && !validating. |
validating | boolean | true while a per-field validation run is in flight. |
displayState | 'idle' | 'pending' | 'error' | 'success' | The single display-state verdict, resolved by getDisplayState. |
showErrors | boolean | displayState === 'error'. The display-time error gate. |
showPending | boolean | displayState === 'pending'. A check has run long enough to earn a spinner. |
showSuccess | boolean | displayState === 'success'. The field has passed. |
showIdle | boolean | displayState === '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.
| Property | Type | Meaning |
|---|---|---|
element | HTMLElement | null | First bound element by registration order. |
elements | readonly 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.
| Property | Type | Meaning |
|---|---|---|
label | string | Registered label, or a humanized fallback from the path's last segment. |
description | string | undefined | Registered description; falls back to schema.describe('...') when no override. |
placeholder | string | undefined | Registered placeholder hint. |
meta | Readonly<FieldMetaPayload> | The full registered payload; escape hatch for consumer-augmented keys. |
path | readonly (string | number)[] | The path tuple that produced this FieldState. |
id | string | Stable, 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. |
key | string | Allocated 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 bykeyso av-forsurvives reorders.values: the value half of every FieldState, lifted to a form-wide Proxy.errors: the per-path errors Proxy, the raw array behindfirstError.meta: the form-level aggregation, every FieldState property rolled up, plus 7 form-only reads.- The
v-registerdirective: the binding that drivestouched,focused,blurred, andconnected. - Display state and showing errors: the predicate behind
displayState. - The
blankfield-state bit: the lifecycle behind theblankcell. - The form: every other reactive read on
formitself.