Building a form
Compose fields into a form, validation, submission, focus, and the states in between.
A form is a list of fields, a legend, one submit control and a rule for what counts as an error. The framework supplies the controls and the states; the page supplies the values and the rules. This page is the shape that works, in the order the pieces matter.
The parts
| Part | Component | Why it is a component rather than markup |
|---|---|---|
| The form | Form |
It owns the legend, the submit control and the pending state, so a form cannot ship without them |
| The fields | the field family | Each renders its label, description, control and message as one unit |
| The steps | Stepper |
Progress, when the form is long enough to need it |
Values belong to the page
Fields are controlled: the page holds the value and the field reports what happened. That is what lets a page validate, reset, prefill or submit without reaching into the DOM.
const [email, setEmail] = useState("");
<Input
label="Email"
value={email}
onChangeText={setEmail}
status={emailError ? "error" : "default"}
message={emailError}
/>
An uncontrolled field keeps its own value, useful for a one-off filter, but a form that submits needs the values, so a form's fields are usually controlled.
Validation is a status and a message
There is no second error channel. A field's message carries the problem and its status
carries the meaning; an "error" message is announced immediately, because a validation
failure the reader has to go looking for is one they will miss.
The useful order is:
- On submit, validate everything and show every message at once, not one per attempt.
- On change after that, re-validate the field the reader is editing, so a fixed field clears its message as they fix it.
- On blur, optionally validate a single field (
email,url, a code) where the answer is already known.
const [errors, setErrors] = useState<Record<string, string>>({});
function submit(event) {
event.preventDefault();
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) return; // keep the page where it is
send(values);
}
Submission and the pending state
Form renders the submit control and takes isPending. While it is pending the control is
busy and cannot be pressed twice, and the label stays put so the row does not move, the
same rule Button follows for its isLoading.
Keep the page where it is while submitting: do not navigate before the request resolves, and do not clear the fields until it succeeds. A form that empties itself on failure has thrown away the reader's work.
Focus
After a failed submit, move focus to the first field with an error. The framework announces the message; it cannot know which field the reader should fix first. A page does that with a ref on the first invalid control:
useEffect(() => {
if (!submitted) return;
const first = Object.keys(errors)[0];
if (first) document.getElementById(first)?.focus();
}, [submitted, errors]);
Two smaller rules: the submit control is a real submit button inside the form's element, so
Enter in a single-line field submits; and a field that is disabled mid-request is
re-enabled in a finally, so a rejected request does not leave the form frozen.
Accessibility checklist
- Every field has a visible
label. A hidden label is still rendered and referenced, it is never omitted. - The message is a
messageprop, not a tooltip and not a colour: it is announced, and it survives a screenshot. - The required marker is decorative; the requirement is on the control.
- Errors are announced when they appear, and focus moves to the first one.
- The form has a
legend, a group of fields without a name is a group a screen reader cannot announce.