跳转到主要内容

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.

TypeScript
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.

TypeScript
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.

TypeScript
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

Forms - Guides - React Docs - React 文档