Skip to contentAsheeUIAsheeUI

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:

  1. On submit, validate everything and show every message at once, not one per attempt.
  2. On change after that, re-validate the field the reader is editing, so a fixed field clears its message as they fix it.
  3. 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 message prop, 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.