Files
baya-monorepo/archive/docs/rules/client/forms.md
T
2026-08-02 20:01:31 +03:30

6.4 KiB

Client forms

Every form with more than one field is a react-hook-form form. This is how you build one.

Last verified: 2026-07-30 against commit d3ec723.


1. The rule, and why it exists

Any form with more than one field uses react-hook-form. A single-field control — a search box, a filter select, a message composer — does not: that is state, not a form.

This is not a style preference. The pattern it replaced was one useState per input plus a parallel useState per error flag. That meant every keystroke re-rendered the whole screen — including the query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?" spread across ad-hoc if blocks at the top of each submit handler.

react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one input.

28 files currently import it. Any multi-field form that still holds its state in useState is a defect to be migrated when that screen is next substantially touched.


2. How to build one

  1. useForm<Values>({ mode: 'onTouched', defaultValues }). onTouched is the house default: an error appears once a field has been visited, never while it is first being typed into.

  2. Wrap the subtree in <FormProvider {...form}> and bind fields with the @/components/common/form wrappers. They read control off the provider, so it is threaded once. Never call register or useController at a call site.

  3. Put the rule on the field it governsrules={{ validate: … }} — returning the translated message. Cross-field rules read validate's second argument (all values); that is how the C4 request form's past-date guard reads the chosen start time.

  4. Render <Stack component="form" noValidate onSubmit={handleSubmit(submit)}> and make the primary button type="submit". Enter-to-submit then works for free.

  5. Async defaults come from a mounted-when-ready child, not an effect. When the initial values depend on a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading branch in the parent and mount the form component only once the data has resolved, so defaultValues is the server state instead of being copied into it later.

Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the form has two sources of truth for a moment, and a user who types during that moment loses the keystroke.


3. The wrappers

Wrapper For
RhfTextField any TextField, including select. transform normalizes keystrokes into form state (digit-stripping, max length) so the stored value is canonical, not just the displayed one. A rule message replaces helperText
RhfChipSelect a chip group over stable codes — string[] (multi) or string | null (single). allowCustomValues keeps a stored code that isn't in the option list visible
RhfJalaliDateField a Jalali date field; stores the wire ISO (Gregorian) string, or null
RhfControlGroup any non-input control — GenderToggle, RatingInput, CascadingRegionSelect, the map-pin picker, a Switch, a Checkbox. Gives it the same label/hint/error shell the text fields get

Every wrapper falls back to the enclosing FormProvider's control, so a form wires it once. All four are tested.

Two conventions worth knowing

  • A control that renders its own error text gets a message-less rulevalidate: (v) => cond, no string. RhfControlGroup then flags the field without printing a second identical line. AddressForm's region and pin fields are the reference.

  • When the displayed value isn't the stored value, drop to a bare Controller. Exactly two cases exist and both are commented at the call site: the variant builder's display-name (stored = the override only; blank means the server names it — shown = the live auto-generated name) and the admin refund channel (stored = "" until explicitly overridden; shown = the server's resolved channel).


4. Structure: FormSection

A long form is grouped into FormSections — a heading, a one-line statement of why the group is being asked for, and an optional/status marker.

The point is that an optional group reads as skippable and a blocked submit has somewhere to attribute itself. A flat run of ten TextFields makes everything look equally mandatory, which is how a nurse ends up abandoning a verification form over a field that was never required.

Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصص‌ها), the verification identity and credentials screens, and the variant builder.


5. Making a gate honest

Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form that validates from a form a user can actually finish.

  • The submit gate is a real form field, not a caption near the bottom. Verification B4's three asks (national id / card photo / selfie) each became a FormSection with the card marked optional and the selfie marked required — so the requirement is attached to the thing that satisfies it.
  • Disable Next with the unanswered required groups named under it. Not "always enabled, error after the tap".
  • Derive a wizard's step list from the loaded data. A category with no option groups skips straight to pricing rather than showing an empty middle step.
  • Recap the chosen values on the final step, so the last step doubles as a review.
  • Never dead-end a returning user on a disabled button with no explanation. If server-side state (an already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must consider it — and a value the server won't read back by design should lock into a "recorded" row rather than re-prompting for it blank.

6. Unsaved work

  • A form hosted in FormDialogShell reports dirty via an onDirtyChange prop, which drives the shell's discard-confirm on close, backdrop and escape.
  • A staged-but-unsaved upload gets a beforeunload guard — the nurse-profile avatar is the reference.
  • A destructive confirm goes through ConfirmDialog, whose destructive and dismiss labels must not be swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.)