Capítulo 9 de 51
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.
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.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.<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).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.Field.Control — required, 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.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).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.<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.Field/Form composition; see each library's own docs for the exact wiring./* 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>
Field.Root name + Field.Error pair, regardless of which validation layer produced the error.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.
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.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.Field.Error) via the errors prop on Form — don't build a separate error-rendering branch for server-side failures.eventDetails-based cancel/reason pattern also applies to Form's own change events.