Per-Field Validation with Visibility
An operator opens a rental request and submits it empty. Three errors appear at once. Then they type the asset tag and the tag error disappears while the other two stay. That is two different behaviours — validate everything, reveal only when allowed — and mixing them into one “submit handler” is where form state usually goes wrong.
This recipe keeps them separate. Validation is a pure function of the form values. Visibility is a flag on the state. Triggers decide whether a run is shown or hidden.
flowchart TD
Event["edit, blur, submit, dismiss"] --> Validate["Validation.keyed.make"]
Validate --> Checks["one Validation per field"]
Checks --> State["FormState: values, checks, shown"]
State --> Gate{"shown?"}
Gate -->|No| Silent["errorsFor returns []"]
Gate -->|Yes| Errors["errorsFor returns field ids"]
Checks --> Invalid["isInvalid / invalidFields"]
Give every rule its own error id
Section titled “Give every rule its own error id”Each field is a list of rules. A rule is a Predicate plus the id it produces when it fails, so the
screen can map ids to copy without parsing messages:
The predicates themselves compose with the Predicate module. A field that
must not be blank uses Predicate.not; a field with two independent rules runs both and keeps both
ids:
Validation.struct runs both rules and keeps every failure. An empty email reports
["REQUIRED_INPUT", "INVALID_EMAIL"] rather than only the first, because two independent checks
ran. The Validation guide covers the accumulation order.
Numeric fields parse first, then check range. A failed parse short-circuits, because there is no number to range-check:
Validate the whole form in one pass
Section titled “Validate the whole form in one pass”Validation.keyed.make applies a validator per field and returns a record of outcomes. Every field
is checked on every run, so the state always knows the complete picture rather than one field at a
time:
The record type is inferred from the validators, so adding a field to the schema is a compile error at every place that builds a form value without it.
Keep visibility on the state
Section titled “Keep visibility on the state”The state holds the values, the latest checks, and one shown flag. A trigger decides whether this
run reveals errors. edit re-validates silently; blur and submit reveal; once revealed, later
edits keep errors visible so a fixed field clears immediately.
init runs a validation pass before the first keystroke. Nothing renders it while shown is false,
which is why an untouched form does not open covered in errors.
Read what the screen needs
Section titled “Read what the screen needs”The UI asks three questions: is it shown, is it invalid, and which fields are invalid. The errors
themselves come from Validation.keyed.getErrors, gated by shown:
A submit handler combines them: validate, reveal, and only send when every field passed. The passed
values are already parsed, so days arrives as a number:
The call order shows the three questions in sequence:
Failure behavior
Section titled “Failure behavior”A field’s errors are a non-empty array of ids, in the order Validation.struct evaluated the rules.
The record from getErrors contains only failing fields, so errors[field] ?? [] is the whole
lookup.
shown is a UI concern, not a validation concern. Resetting it with dismiss keeps the checks, so
the next trigger can reveal the same result without validating again.
Rules, mappers, and subscribers must not throw; a predicate that throws escapes through
Validation.struct and the form stops updating. Keep each rule a pure boolean test and put anything
fallible behind Result before it reaches the schema.
When one field’s validity depends on another, validate the pair in one rule that reads both values;
keyed validators receive only their own field. The choosing guide explains
when this belongs in Validation rather than Result.