Files
baya-monorepo/docs/rules/client/forms.md
T
2026-07-30 02:26:52 +03:30

117 lines
6.4 KiB
Markdown

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