Create accessible, resilient form workflows with native semantics, layered validation, secure server boundaries, clear errors, and reliable submission state.
Build forms from native semantic controls and labels
Layer client feedback over authoritative server validation
Represent field and submission errors accessibly
Handle asynchronous checks, files, and repeated values safely
Design submission workflows that survive retries and failures
A form groups controls into one submission intent and provides browser behaviors for keyboard activation, autofill, validation, and accessibility. Use native controls before recreating them with generic elements.
Buttons inside a form default to submission in HTML. Declare button types intentionally so secondary actions do not trigger requests unexpectedly.
Use form and submit semantics for the primary action
Choose the most specific native input type
Declare non-submit buttons with type button
<form method="post" action="/subscribe">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<button type="submit">Subscribe</button>
<button type="button">Learn more</button>
</form>Every input needs an accessible name, usually from a visible label connected with for and id. Placeholder text is not a label because it disappears and often has weak contrast.
Use autocomplete tokens that describe the data's purpose. Connect persistent help and error text with aria-describedby so assistive technology can discover it in context.
Give every control a persistent accessible name
Use standard autocomplete purpose tokens
Reference help and errors from the control
<label for="new-password">Create password</label>
<input
id="new-password"
name="password"
type="password"
autocomplete="new-password"
aria-describedby="password-hint"
>
<p id="password-hint">Use at least 12 characters.</p>Required, minlength, maxlength, pattern, min, max, and input types express constraints the browser can evaluate. They provide a useful baseline even when JavaScript fails to load.
Native validity is client-side convenience, not a security boundary. Server validation must enforce the same business rules against the submitted representation.
Express simple constraints in HTML
Use the ValidityState API for customized feedback
Repeat every authoritative rule on the server
const username = document.querySelector("input[name=username]");
username?.addEventListener("input", () => {
if (username.validity.tooShort) {
username.setCustomValidity("Use at least 3 characters.");
} else {
username.setCustomValidity("");
}
});FormData follows the browser's successful-control rules. Disabled controls and unchecked checkboxes are omitted, while repeated names and multi-select fields can produce several values.
Read values as string or File and use getAll for repeated fields. Convert numbers and booleans explicitly rather than assuming the browser changes their runtime type.
Understand which controls participate in submission
Use getAll for repeated names
Parse scalar types at the validation boundary
function readInterests(form: HTMLFormElement): string[] {
return new FormData(form)
.getAll("interest")
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean);
}Showing errors on the first keystroke punishes normal input. Validate on submission, then revalidate a field as the user edits it after an error has become relevant.
Use blur feedback selectively for fields that are complete as a unit. Preserve the user's value and avoid clearing errors before the underlying problem is fixed.
Do not show premature errors while input is incomplete
After submission, update relevant errors as users correct them
Preserve values through validation failures
type FieldState = { value: string; touched: boolean; submitAttempted: boolean };
function shouldShowError(state: FieldState, error: string | null): boolean {
return error !== null && (state.touched || state.submitAttempted);
}Availability checks and remote validation can finish out of order. Cancel superseded requests or associate every result with the exact value that produced it.
A positive availability hint is still not a reservation. The final server submission must handle conflicts that occur after the check.
Debounce only when it improves network behavior
Cancel or ignore stale validation responses
Recheck uniqueness atomically during submission
let validationController: AbortController | null = null;
async function checkUsername(value: string): Promise<boolean> {
validationController?.abort();
validationController = new AbortController();
const url = new URL("/api/usernames/available", location.origin);
url.searchParams.set("value", value);
const response = await fetch(url, { signal: validationController.signal });
return response.ok && (await response.json()).available === true;
}Field errors should appear beside their control and be programmatically associated with it. An error summary helps users understand a failed submission, especially when several fields are invalid.
Move focus to the summary after an unsuccessful submit, then provide links to affected fields. Do not announce every keystroke through an aggressive live region.
Associate each field error with aria-describedby
Focus a submission-level error summary
Write specific recovery instructions instead of generic invalid labels
<label for="email">Email</label>
<input id="email" name="email" type="email" aria-invalid="true" aria-describedby="email-error">
<p id="email-error">Enter an address in the form name@example.com.</p>File inputs provide File objects and require multipart handling when sent with a native form. Client attributes such as accept improve selection but do not prove content type or safety.
Validate size, media type, extension, and file content on the server, then store uploads outside publicly executable locations with generated names.
Treat accept as a picker hint
Validate file content and limits on the server
Do not manually set the multipart boundary header
function validateAvatar(value: FormDataEntryValue | null): string | null {
if (!(value instanceof File) || value.size === 0) return "Choose an image.";
if (value.size > 2_000_000) return "Choose an image smaller than 2 MB.";
if (!value.type.startsWith("image/")) return "Choose an image file.";
return null;
}Clients can be modified or bypassed, so the server must authenticate the request, authorize the operation, validate every field, and enforce cross-record constraints. Return bounded errors without exposing internals.
Keep submitted values when rendering recoverable errors, but never echo secrets such as passwords. Protect state-changing forms against cross-site request forgery according to the authentication model.
Derive identity and entitlement on the server
Validate the submitted representation before domain work
Return field-safe errors without reflecting sensitive data
type ValidationResult<T> =
| { ok: true; value: T }
| { ok: false; fieldErrors: Readonly<Record<string, string>> };
type ProfileInput = { displayName: string };
function validateProfile(input: unknown): ValidationResult<ProfileInput> {
if (!input || typeof input !== "object") return { ok: false, fieldErrors: { form: "Invalid request." } };
return { ok: true, value: { displayName: "Validated value" } };
}A form can be idle, validating, submitting, successful, or failed. Represent those states explicitly so controls, messages, and duplicate-submission protection stay consistent.
Disable only the actions that would conflict, preserve the user's content, and make retry behavior safe. Server operations should be idempotent when a network retry can repeat them.
Represent submission states explicitly
Prevent duplicate conflicting requests while pending
Design retries around idempotent server behavior
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "success"; message: string }
| { status: "error"; message: string; canRetry: boolean };
function isPending(state: SubmitState) {
return state.status === "submitting";
}Test forms through labels, roles, values, and user-visible messages. Cover keyboard submission, invalid input, slow and failed requests, server conflicts, retries, and restoration after navigation.
A robust form should remain understandable at zoom, with JavaScript delayed, and after a failed submission. Automation should complement manual keyboard and assistive-technology review.
Test the workflow through accessible queries
Exercise failure, retry, and duplicate-submission paths
Verify the semantic baseline as well as enhanced behavior
1. Focus the email field through its label.
2. Submit an invalid address and verify the linked error.
3. Correct the value and submit once while the request is pending.
4. Simulate a server conflict and preserve the entered value.
5. Retry successfully and move focus to confirmation.