# 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({ 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 `` 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 governs** — `rules={{ 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 ``** 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 rule** — `validate: (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 `FormSection`s — 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 `TextField`s 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.)