Capítulo 9 de 51

Chapter 9: Forms

Core Idea

Base UI form controls extend the native constraint validation API and route everything through <Field.Root name="..."> wrappers — accessible naming, native + custom + server-side validation, and error display are all driven by the Field/Fieldset/Form trio, and the same controls integrate directly with React Hook Form or TanStack Form when you need externally-managed form state.

Key Concepts

  • Naming form controls (accessibility): input-like controls (Input, NumberField, OTPField, Autocomplete, Combobox input-outside-popup, Checkbox, Radio, Switch) take <Field.Label> or a native <label>Checkbox/Radio/Switch can be implicitly labeled by wrapping them inside <Field.Label>. Trigger-based controls (Combobox input-inside-popup, Select, Slider) use their own .Label part (Select.Label, Slider.Label) instead — for multi-thumb Slider, also set aria-label per Slider.Thumb to distinguish thumbs. No visible label → fall back to aria-label directly on the control. <Field.Description> auto-wires an accessible description via aria-describedby.
  • Grouping controls: wrap a Fieldset.Root (with render pointing at the group primitive, e.g. render={<RadioGroup />}) around multiple related controls, with <Fieldset.Legend> as the group's single label. Wrap each individual checkbox/radio option in <Field.Item> so each still gets its own Field.Label/Field.Description.
  • Registering a value: give the enclosing <Field.Root name="..."> a name — that's what makes the wrapped control's value appear in the parent <Form>'s submitted data (works even for non-native controls like Combobox).
  • Submitting: either take over the native onSubmit(event) and build a FormData yourself (event.preventDefault() first), or use onFormSubmit(formValues, eventDetails) which hands you a plain object of field values and already calls preventDefault() for you.
  • Constraint validation: native HTML attributes work directly on Field.Controlrequired, minLength/maxLength, pattern, step. Base UI backs every control with a hidden input for native form participation; when validation bubbles need to point at the right spot, give the control a name and wrap it in a position: relative container.
  • Custom validation: pass a sync or async function to Field.Root's validate prop — runs after native validation passes; return a string error message or null/undefined for valid. Control timing with validationMode: onSubmit (default, revalidates on change once invalid), onBlur, or onChange; debounce with validationDebounceTime (essential for async/onChange validation like a username-availability check).
  • Server-side validation: pass an errors object ({ [fieldName]: string | string[] }) to <Form errors={...}> — merges into client-side field state; clears automatically once the field's value changes. Works naturally with React 19 Server Functions: return errors from a useActionState action and pass state.errors straight to Form.
  • Displaying errors: <Field.Error /> with no children auto-shows the native validity message; use the match prop (e.g. match="valueMissing") to override the message per validity-state key — also the mechanism for i18n of error text.
  • Third-party integration: React Hook Form and TanStack Form both integrate by managing field/form state externally while Base UI controls remain the rendered inputs — no special Base UI API beyond the standard Field/Form composition; see each library's own docs for the exact wiring.

Code Examples

/* Field naming + description */
<Field.Root name="country">
  <Field.Label>Country of residence</Field.Label>
  <Combobox.Root />
  <Field.Description>Used for notifications and reminders</Field.Description>
</Field.Root>
/* Grouped controls via Fieldset */
<Fieldset.Root render={<RadioGroup />}>
  <Fieldset.Legend>Storage type</Fieldset.Legend>
  <Radio.Root value="ssd" />
  <Radio.Root value="hdd" />
</Fieldset.Root>
/* Async custom validation, debounced */
<Field.Root name="username" validationMode="onChange" validationDebounceTime={300}
  validate={async (value) => {
    if (value === 'admin') return 'Reserved for system use.';
    const available = await checkUsername(value);
    return available ? null : `${value} is unavailable.`;
  }}>
  <Field.Control required minLength={3} />
  <Field.Error />
</Field.Root>
/* Server-side errors from a Server Action */
const [state, formAction] = React.useActionState(login, {});
<Form action={formAction} errors={state.errors}>
  <Field.Root name="password"><Field.Control /><Field.Error /></Field.Root>
</Form>
  • What it demonstrates: the full stack from accessible naming through client validation to server error display all composes through the same Field.Root name + Field.Error pair, regardless of which validation layer produced the error.

Worked Example

A promo-code field validated three ways at once: native required, a debounced async availability check, and a server-rejected code surfaced after submit.

const [errors, setErrors] = React.useState();

<Form errors={errors} onSubmit={async (event) => {
  event.preventDefault();
  const { errors: serverErrors } = await submitToServer(new FormData(event.currentTarget));
  setErrors(serverErrors); // e.g. { promoCode: 'This promo code has expired' }
}}>
  <Field.Root name="promoCode" validationMode="onBlur"
    validate={(value) => (value.length !== 8 ? 'Promo codes are 8 characters.' : null)}>
    <Field.Label>Promo code</Field.Label>
    <Field.Control required />
    <Field.Error />
  </Field.Root>
  <button type="submit">Apply</button>
</Form>

Client-side required and length validate run first (on blur); only once those pass does submission reach the server, whose promoCode error is merged in via the errors prop and auto-clears the moment the user edits the field again.

Key Takeaways

  1. Always name the wrapping Field.Root, not the inner control — that's what makes the value participate in Form submission and error matching, for both native and non-native (Combobox, Select) controls.
  2. Pick validationMode deliberately: onChange + validationDebounceTime for async/expensive checks, onBlur for medium-cost checks, default onSubmit for cheap synchronous rules — using onChange without debouncing an async validator will hammer the backend on every keystroke.
  3. Server errors and client errors share one display path (Field.Error) via the errors prop on Form — don't build a separate error-rendering branch for server-side failures.

Connects To

  • ch023 (Field), ch024 (Fieldset), ch025 (Form), ch026 (Input): the component-level API reference for every part used in this chapter.
  • ch003 (Accessibility): the naming rules here are the concrete implementation of the accessible-labels guidance from that chapter.
  • ch008 (Customization): eventDetails-based cancel/reason pattern also applies to Form's own change events.