Add the frontend guides the v2 UI kit and compliance-portal need but that the first rework left uncovered: forms, routing, client state, and permission-gated UI. forms.md documents a tiered approach on Base UI Field/Form -- native constraints, then a validate function, then zod parsed in onSubmit, and react-hook-form only for large or dynamic forms -- and drops the custom useFormWithSchema wrapper. routing.md covers @probo/routes, navigation, typed params, URL-as-state, redirects, auth/protected routes, and the folded-in no-outlet-context rule. state-management.md gives a decision order across Relay, URL, local state, context, and zustand. permissions.md gates UI on the canUpdate/canDelete permission(action:) fields without re-encoding authorization in the client. Rename v2-colors.md to v2-tokens.md and add the typography, radius, shadow, and native-spacing scales alongside color. Extend ui.md with user feedback, empty-state, and accessibility sections; standardize toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an Intl formatting section to i18n.md and a non-Relay HTTP / file upload-download section to ts-style.md. Update the AGENTS.md index and the v2-color-scale cursor rule for the new and renamed guides. Signed-off-by: Émile Ré <emile@probo.com>
8.8 KiB
Forms and validation
Forms in the v2 apps are built on Base UI Form + Field, which extend the native Constraint Validation API. The guiding rule matches the rest of the UI kit: reach for the lightest tier that does the job, and don't pile a form abstraction on top of the headless library when native validation already covers the case.
There is no custom form hook. The legacy useFormWithSchema (react-hook-form + zod wrapper) is not used in v2 — it forces every form, however trivial, onto react-hook-form + zod and hides which validation tier you are actually in.
Related guides
| Topic | Guide |
|---|---|
Component shape, *Form / *Field suffixes |
contrib/claude/react-components.md |
| Styling primitives, Base UI | contrib/claude/ui.md |
| Mutations, server errors, toasts | contrib/claude/relay.md |
| Error/fallback handling | contrib/claude/error-handling.md |
| Translating labels and messages | contrib/claude/i18n.md |
The four tiers
Pick the lowest tier that expresses your validation. Do not mix tiers within one form.
| Tier | Tool | Use when |
|---|---|---|
| 1 | Base UI Field + native constraints |
The default. Validation is expressible as required, type, minLength, maxLength, min, max, pattern. |
| 2 | Base UI Field + validate function |
A rule native constraints can't express: cross-field (confirm password), conditional, async uniqueness checks. |
| 3 | zod schema parsed in onSubmit, mapped to Form errors |
Validation is genuinely complex, needs specific custom messages, or you want one typed schema as the source of truth (possibly shared). No react-hook-form. |
| 4 | react-hook-form + zodResolver |
Large/dynamic forms: many fields, field arrays, wizards, heavy conditional logic, or perf with many controlled inputs. |
Server-side errors map onto the Form errors prop in every tier (see Server errors).
Tier 1 — native constraints (default)
Most forms need nothing more. Base UI validates native HTML constraints, and <Field.Error> renders the message. Use match to supply your own copy (and i18n) per validity state.
import { Form } from "@base-ui-components/react/form";
import { Field } from "@base-ui-components/react/field";
import { useTranslation } from "react-i18next";
export function CreateMeasureForm({ onSubmit }: CreateMeasureFormProps) {
const { t } = useTranslation();
return (
<Form onSubmit={onSubmit}>
<Field.Root name="name">
<Field.Label>{t("measures.form.name")}</Field.Label>
<Field.Control required maxLength={120} />
<Field.Error match="valueMissing">{t("measures.form.nameRequired")}</Field.Error>
<Field.Error match="tooLong">{t("measures.form.nameTooLong")}</Field.Error>
</Field.Root>
<Button type="submit">{t("common.save")}</Button>
</Form>
);
}
Field.Controlrenders a native input by default; pass a Base UI input/select/checkbox viarender(or nest one) when you need a styled control. The kit's styled inputs live underform/(seeui.md).
Tier 2 — custom validate
When the rule isn't a native constraint, add a sync/async validate on Field.Root. It returns a message string (or array) when invalid, or null when valid, and runs after native constraints pass. Stay in Base UI — do not introduce a library for this.
<Field.Root
name="confirmPassword"
validate={(value, formValues) =>
value === formValues.password ? null : t("auth.passwordsDoNotMatch")
}
>
<Field.Label>{t("auth.confirmPassword")}</Field.Label>
<Field.Control type="password" required />
<Field.Error />
</Field.Root>
Async validate (e.g. a uniqueness check) is supported; note Base UI does not block submit on a pending async validation in validationMode="onSubmit".
Tier 3 — zod, without react-hook-form
When validation is complex or needs specific messages, parse a zod schema in onFormSubmit and feed the flattened field errors to Form's errors prop. This keeps a single typed schema as the source of truth without pulling in react-hook-form.
import { Form } from "@base-ui-components/react/form";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "measures.form.nameRequired"),
weight: z.coerce.number().min(0).max(100),
});
export function CreateMeasureForm({ onValid }: CreateMeasureFormProps) {
const [errors, setErrors] = useState<Record<string, string | string[]>>({});
return (
<Form
errors={errors}
onClearErrors={setErrors}
onFormSubmit={(formData) => {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) {
setErrors(z.flattenError(result.error).fieldErrors);
return;
}
onValid(result.data);
}}
>
<Field.Root name="name">
<Field.Label>{t("measures.form.name")}</Field.Label>
<Field.Control />
<Field.Error />
</Field.Root>
{/* … */}
</Form>
);
}
Reserve zod for the cases above — a z.object({ name: z.string().min(1) }) that only restates a required attribute belongs in tier 1.
Tier 4 — react-hook-form
Only when the form is large or dynamic enough that react-hook-form's machinery pays for itself. Use useForm + zodResolver directly (no project wrapper hook), and wire Base UI Field.Control through register / Controller.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
If you find yourself reaching for tier 4 for an ordinary CRUD dialog, step back — it is almost always tier 1 or 3.
Server errors
Validation that only the backend can perform (uniqueness, business rules — see validation.md for the error codes the API returns) comes back from the mutation. Map it onto the Form errors prop keyed by field name; Base UI clears each entry when the field changes. Use formatError from @probo/helpers to turn a GraphQL error into a message.
const toast = Toast.useToastManager();
const [createMeasure, isCreating] = useMutation<CreateMeasureMutation>(createMeasureMutation);
const [errors, setErrors] = useState<Record<string, string | string[]>>({});
function onValid(input: MeasureInput) {
createMeasure({
variables: { input, connections: [connectionId] },
onCompleted() {
toast.add({ title: t("measures.created"), type: "success" });
},
onError(error) {
// Field-specific server errors → the form; otherwise → a toast.
const fieldErrors = toFieldErrors(error);
if (fieldErrors) {
setErrors(fieldErrors);
} else {
toast.add({
title: t("common.error"),
description: formatError(t("measures.createFailed"), error as GraphQLError),
type: "error",
});
}
},
});
}
- Field-level server errors →
Formerrors. - Whole-form / unexpected errors → a toast (see
error-handling.mdandui.md). - Never rely on a page reload to surface a failed submit.
Submit, loading, and disabled state
- Disable the submit control while the mutation is in flight (
isCreating/isUpdating— see the mutation naming inrelay.md). - Keep the form responsive: do not unmount fields on submit; let Base UI manage validity.
- For destructive submits, wrap in
useConfirm(seerelay.md).
Dialogs
Forms in dialogs use the Base UI dialog directly — controlled with open / onOpenChange. Do not use the legacy useDialogRef / imperative open() pattern (see ui.md).
// Bad — imperative ref to open a form dialog (legacy console pattern)
const ref = useDialogRef();
ref.current?.open();
// Good — controlled open state, same API as the Base UI dialog
const [open, setOpen] = useState(false);
<Dialog open={open} onOpenChange={setOpen}>{/* … <CreateMeasureForm /> … */}</Dialog>
Do / don't summary
// Bad
- Custom useFormWithSchema wrapper around every form
- zod schema that only restates `required` / `maxLength`
- react-hook-form for a two-field dialog
- Mixing native constraints and a zod parse in the same form
- Opening a form dialog through an imperative ref
// Good
- Native Base UI constraints by default
- `validate` for cross-field / async rules
- zod (no RHF) when validation is genuinely complex
- react-hook-form only for large / dynamic forms
- Server errors mapped onto the Form `errors` prop