# 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 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 `<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 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.)
