Forms
The hard part is rarely the input itself—it is state syncing, validation, accessibility, and submission UX.
Controlled vs uncontrolled
Controlled inputs are predictable; uncontrolled inputs are closer to the platform and can be cheaper. Choose based on validation, cross-field logic, and serialization needs.
A practical way to think about it: if your UI needs to react to every keystroke, you probably need controlled state; if you only need values on submit, uncontrolled inputs keep things simpler and faster.
- Prefer uncontrolled for simple “submit once” forms.
- Prefer controlled for live validation, conditional UI, and cross-field dependencies.
Baseline: native semantics first
Even in React, forms work best when you lean on the platform: proper labels, names, input types, autocomplete, and submit semantics.
If you get the semantics right, you get keyboard support, autofill, and assistive technology support almost “for free”.
- Always connect `<label>` and `<input>` (`htmlFor`/`id`) to expand the click target and help screen readers.
- Use input `type` (`email`, `password`, `number`) and `autoComplete` (`email`, `current-password`) to improve UX.
- Prefer a real `<form>` with a submit button so `Enter` and native validation work.
Uncontrolled example (FormData)
For “submit once” forms, uncontrolled inputs avoid per-keystroke state syncing. You can read values at submit time via `FormData`.
This approach scales surprisingly far for basic CRUD screens, and it keeps your React state focused on UI—not raw input bytes.
export function SignupForm() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
const email = String(fd.get('email') ?? '');
const password = String(fd.get('password') ?? '');
// 这里做最小校验:真实校验仍应在服务端再次进行
if (!email || !password) return;
// await api.signup({ email, password })
form.reset();
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" autoComplete="new-password" required />
<button type="submit">Create account</button>
</form>
);
}Validation and error UI
Make invalid states explicit, keep messages actionable, and connect messages to inputs.
A good error message answers two questions: “what is wrong” and “what can I do now”. Make it specific and keep it close to the field.
import { useId, useState } from 'react';
export function EmailField() {
const id = useId();
const errorId = `${id}-error`;
const [value, setValue] = useState('');
const [touched, setTouched] = useState(false);
const isValid = value === '' || /^[^@]+@[^@]+\.[^@]+$/.test(value);
const showError = touched && !isValid;
return (
<div>
<label htmlFor={id}>Email</label>
<input
id={id}
name="email"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={showError}
aria-describedby={showError ? errorId : undefined}
autoComplete="email"
/>
{showError ? (
<p id={errorId} role="alert">
Please enter a valid email.
</p>
) : null}
</div>
);
}Submission UX
Submission is where many forms fail in practice: double submits, unclear progress, and errors that don’t tell users what to do.
- Prevent double submits: disable the submit button while `pending` and show progress.
- Keep errors actionable: show an error summary + field-level messages.
- On success, confirm clearly: toast + UI state update + `reset` only if appropriate.
Focus the first invalid field
A fast win for accessibility: after submit, focus the first invalid field. This makes keyboard and screen-reader flows dramatically better.
In large forms, users often miss the first error and keep clicking submit. Focusing the first invalid input short-circuits that frustration.
function focusFirstInvalid(form: HTMLFormElement) {
const el = form.querySelector<HTMLElement>('[aria-invalid="true"], :invalid');
el?.focus();
}
export function Example() {
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
// 如果你自己做校验:先把 aria-invalid 设置好,然后再 focus
focusFirstInvalid(form);
}
return (
<form onSubmit={onSubmit}>
{/* ... */}
<button type="submit">Submit</button>
</form>
);
}Performance tips
Most form perf issues come from broad rerenders. The fix is usually to reduce the scope of updates, not to micro-optimize input components.
- Avoid top-level “form state object” that updates on every keystroke across many fields.
- Keep expensive validation async/debounced, and validate on `blur`/`submit` when appropriate.
Production checklist
- All fields have `label`/`name`/`type`/`autoComplete`; form is usable with keyboard only.
- Server validates again—never trust client validation for permissions, pricing, or security.
- Errors are announced and actionable (`role="alert"`, `aria-describedby`, focus management).
- Submit is idempotent or guarded (disable while `pending`, request dedupe on the server).
Common pitfalls
- Making every field controlled without isolating updates, causing typing lag.
- Error messages not connected to inputs (missing aria-describedby/role), breaking screen readers.
Further reading
- Learn: State as a snapshot
- Guides: Accessibility