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
-
useForm<Values>({ mode: 'onTouched', defaultValues }).onTouchedis the house default: an error appears once a field has been visited, never while it is first being typed into. -
Wrap the subtree in
<FormProvider {...form}>and bind fields with the@/components/common/formwrappers. They readcontroloff the provider, so it is threaded once. Never callregisteroruseControllerat a call site. -
Put the rule on the field it governs —
rules={{ validate: … }}— returning the translated message. Cross-field rules readvalidate's second argument (all values); that is how the C4 request form's past-date guard reads the chosen start time. -
Render
<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>and make the primary buttontype="submit". Enter-to-submit then works for free. -
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
defaultValuesis 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 rule —
validate: (v) => cond, no string.RhfControlGroupthen 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
FormSectionwith 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
FormDialogShellreportsdirtyvia anonDirtyChangeprop, which drives the shell's discard-confirm on close, backdrop and escape. - A staged-but-unsaved upload gets a
beforeunloadguard — 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.)