Error display
Validation errors live on field.errors, but rendering them takes
more than presence — most apps want errors to surface only after a
submit attempt or after the user has actually interacted with a
field. Spelling that heuristic out at every error site bloats
templates and drifts across components:
<!-- Don't — the heuristic is repeated everywhere it renders -->
<input v-register="register('email')" />
<span
v-if="
form.fields.email.errors.length > 0 &&
(form.meta.submitCount > 0 || (form.fields.email.touched && form.fields.email.dirty))
"
>
{{ form.fields.email.errors[0].message }}
</span>
field.showErrors and field.firstError collapse the whole pattern
to a two-line idiom:
<input v-register="register('email')" />
<span v-if="form.fields.email.showErrors">
{{ form.fields.email.firstError?.message }}
</span>
showErrors is the gate, firstError is the data. Each is reactive,
each is independent, each works on leaf and container paths alike.
The two primitives
field.showErrors
true when there are errors at this path AND the configured
heuristic decides they're ready to render. The framework gates the
predicate on errors.length > 0, so showErrors is false
whenever there's nothing to show — regardless of the heuristic.
field.firstError
The first ValidationError at this path in deterministic
schema-declaration order, or undefined when there are none. Pure
data — independent of showErrors. Reach for it when you want a
single highest-priority message; reach for field.errors when you
want the full list.
firstError is errors[0] — the underlying ordering is stable
(schema → blank → user errors at one path; bucketed by
pathOrdinal across descendants for container paths) so the same
set of errors always produces the same firstError.
The default heuristic
Out of the box:
const defaultShouldShowErrors = (field, formMeta) =>
formMeta.submitCount > 0 || (field.touched === true && field.dirty)
"Show errors after the first submit attempt OR after the user has interacted with the field AND made a change." Standard form UX — errors stay quiet until they're actionable.
Override per form
useForm({
schema,
shouldShowErrors: (field, formMeta) => formMeta.submitCount > 0,
})
Per-form override beats the plugin default and beats the library default.
Override app-wide
import { createAttaform } from 'attaform'
createApp(App).use(
createAttaform({
defaults: {
shouldShowErrors: (field, formMeta) => formMeta.submitCount > 0 || field.touched === true,
},
})
)
Every form in the app inherits this heuristic unless it sets its own.
Boolean shorthand
useForm({ schema, shouldShowErrors: true }) // always show when errors exist
useForm({ schema, shouldShowErrors: false }) // never show — adopters who gate manually
true means "show errors whenever any exist"; false means "never
gate via showErrors — read firstError / errors directly and
write your own template logic." true does NOT render an empty
errors block — the framework still gates on errors.length > 0.
Compose with defaultShouldShowErrors
The library default is publicly exported. Layered predicates that add a special case but otherwise defer to the library default pick up future heuristic refinements automatically:
import { defaultShouldShowErrors } from 'attaform'
useForm({
schema,
shouldShowErrors: (field, formMeta) =>
field.path[0] === 'urgent' || defaultShouldShowErrors(field, formMeta),
})
If a future Attaform release tweaks the default heuristic (say, to also fire on blur), every layered predicate that defers to the default inherits the change without code edits.
Container paths
Both primitives work on container paths — render row-level or section-level summary errors with the same idiom:
<div v-for="(_, i) in form.values.users" :key="i">
<input v-register="register(['users', i, 'name'])" />
<input v-register="register(['users', i, 'email'])" />
<!-- One row-level summary instead of per-field repetition -->
<span v-if="form.fields.users[i]?.showErrors" class="row-error">
{{ form.fields.users[i]?.firstError?.message }}
</span>
</div>
For a container, firstError is the first error in the aggregated
subtree (descendants sorted by schema-declaration order); showErrors
runs the predicate against the container's aggregated state
(touched becomes "any descendant touched", dirty becomes "any
descendant dirty", etc.). Same predicate; uniform across depth.
form.meta.showErrors
form.meta is the root container's FieldState plus the form-level
lifecycle fields, so form.meta.showErrors and
form.meta.firstError are the form-wide rollup:
<div v-if="form.meta.showErrors" class="form-summary">
Please fix the {{ form.meta.errors.length }} highlighted issue(s).
</div>
Form-level errors
Messages that aren't tied to a single field — "capacity exceeded",
"this combination already exists", server-side failures from a
submit handler — live in a dedicated bucket at the empty-string
path ['']. Write them with form.setFormErrors, clear them with
form.clearFormErrors:
form.setFormErrors([{ message: 'Capacity exceeded' }])
form.setFormErrors([{ message: 'Network unreachable', code: 'api:network' }])
form.clearFormErrors() // or setFormErrors([])
Render them above (or below) the form by reading the empty-path bucket directly:
<template>
<div v-if="form.errors('')" role="alert" class="form-banner">
<p v-for="e in form.errors('')" :key="e.message">{{ e.message }}</p>
</div>
<input v-register="form.register('email')" />
<!-- field errors as usual -->
</template>
Three read paths surface the bucket:
form.errors('')— call-form with the empty-string path, returnsValidationError[] | undefined. Use this in templates and script.form.errors['']— bracket access on the drill proxy.form.meta.errors— the flat aggregate (all errors at every depth, form-level included).
form.errors.<field> dot-access skips the bucket because there's no
'' property in JS dot-notation. That's the only surface where the
bucket is invisible; iteration / JSON.stringify(form.errors) /
template interpolation all show it at the empty-string key.
Form-level entries:
- Survive
setFieldErrors— the two surfaces own logically distinct slots, so writing field errors doesn't wipe the form banner and vice versa. - Survive
clearFieldErrors()— the no-path "clear all field errors" call leaves the form-level bucket alone. PassclearFormErrors()to drop them explicitly. - Are NOT cleared on schema revalidation — they're user-owned data; the consumer manages their lifetime.
- ARE cleared by
reset()— reset returns the form to its initial state, which has no errors.
For surfacing server-returned failures from a submit handler, see server errors > non-field errors.
Predicate signature
type ShouldShowErrors = (
field: Omit<FieldState, 'showErrors' | 'firstError'>,
formMeta: Omit<FormMeta, 'showErrors' | 'firstError'>
) => boolean
Both arguments omit showErrors / firstError — those are derived
FROM this predicate, so reading them inside would be a self-
reference. The omit is enforced at the type level AND at runtime
(the keys literally are not present on the objects), so cycles are
impossible whether you're writing TypeScript, vanilla JS, or
casting through as.
The predicate must be pure and SSR-safe. It runs inside Vue
computeds; reading reactive state (field.touched,
formMeta.submitCount, etc.) registers as a dependency
automatically, but DOM access (window, document) breaks SSR.
Opting out of showErrors
Adopters who want full control read field.errors (or
field.firstError for the sugar) and gate rendering with their own
template logic:
<span v-if="form.fields.email.errors.length > 0 && customGate">
{{ form.fields.email.firstError?.message }}
</span>
firstError is always available; showErrors is the convenience.