feat: add useConditionalField hook and implement reactive form showcase examples
This commit is contained in:
@@ -362,6 +362,123 @@ it('minValue() should enforce min', () => {
|
||||
|
||||
---
|
||||
|
||||
## Reactive Form Logic: useConditionalField
|
||||
|
||||
To decouple complex rendering side-effects from your component's root render function, the `@repo/ui/hooks` module provides `useConditionalField`. This hook automatically cleans up React Hook Form fields based on dynamic boolean conditions, enabling efficient micro-subscription architectures via `useWatch`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The hook exclusively uses a strict `UseConditionalFieldOptions` object signature. Legacy positional parameters are no longer supported to ensure strict typing and predictability across the monorepo.
|
||||
|
||||
### Core Modes
|
||||
|
||||
The hook supports two cleanup strategies defined by the `mode` parameter:
|
||||
|
||||
| Mode | Behavior | Use Case |
|
||||
|---|---|---|
|
||||
| `unregister` | Completely unmounts the field. Value is wiped. Key is removed from submission payload. | Hidden fields (e.g. Spouse Name if "Single" is checked). |
|
||||
| `reset` | Field stays active/disabled. Value is wiped. Error state is cleared. Key is sent in payload as empty/default. | Disabled or Cascading fields (e.g. Email Input if "Subscribe" is false, or resetting City when Province changes). |
|
||||
|
||||
### Hook Configuration
|
||||
|
||||
```tsx
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
|
||||
export function ExampleForm() {
|
||||
const { control, setValue, unregister, clearErrors } = useForm();
|
||||
|
||||
const userType = useWatch({ control, name: 'userType' });
|
||||
const newsletter = useWatch({ control, name: 'newsletter' });
|
||||
|
||||
// 1. Unregister Mode (Hidden Field)
|
||||
useConditionalField({
|
||||
condition: userType === 'CORPORATE',
|
||||
name: 'corporateTaxId',
|
||||
setValue,
|
||||
unregister,
|
||||
mode: 'unregister'
|
||||
});
|
||||
|
||||
// 2. Reset Mode (Visible but Disabled)
|
||||
useConditionalField({
|
||||
condition: newsletter === true,
|
||||
name: 'newsletterEmail',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset'
|
||||
});
|
||||
|
||||
return <form>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
### Cascading Dropdowns & Reactivity
|
||||
|
||||
When dealing with cascading dependencies (e.g., Department -> Role), changing the parent dropdown should invalidate and reset the child dropdown.
|
||||
|
||||
You can accomplish this easily by supplying `mode: 'reset'` to `useConditionalField`. However, there is a **critical rendering caveat** with Mantine's `Select` (and similar complex visual inputs):
|
||||
|
||||
> [!WARNING]
|
||||
> **The Dynamic Key Trick:** Mantine components aggressively cache their internal visual text state. Even if `useConditionalField` perfectly resets the React Hook Form payload state to `''`, Mantine may still visually display the old, stale text on the screen.
|
||||
>
|
||||
> To fix this UI desync, you **must bind the parent dependency to the child component's `key` prop**. This forces React's reconciliation engine to completely unmount and remount the child DOM node, flushing Mantine's internal cache and guaranteeing perfect UI synchronization.
|
||||
|
||||
#### Master Example: Department to Role Cascade
|
||||
|
||||
```tsx
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useConditionalField } from '@repo/ui/hooks';
|
||||
import { FieldSelect } from '@repo/ui/form';
|
||||
|
||||
export function DepartmentForm() {
|
||||
const { control, setValue, clearErrors } = useForm();
|
||||
|
||||
const department = useWatch({ control, name: 'department' });
|
||||
const role = useWatch({ control, name: 'role' });
|
||||
|
||||
// Derive available options based on the parent state
|
||||
const currentRoleOptions = department === 'IT'
|
||||
? [{ value: 'FRONTEND', label: 'Frontend' }, { value: 'BACKEND', label: 'Backend' }]
|
||||
: [];
|
||||
|
||||
// Determine if the currently selected role is still mathematically valid
|
||||
const isRoleValid = !role || (!!department && currentRoleOptions.some(opt => opt.value === role));
|
||||
|
||||
// 3. Reset Mode: Automatically wipes the field value in the RHF Payload if it becomes invalid
|
||||
useConditionalField({
|
||||
condition: isRoleValid,
|
||||
name: 'role',
|
||||
setValue,
|
||||
clearErrors,
|
||||
mode: 'reset',
|
||||
defaultValue: ''
|
||||
});
|
||||
|
||||
return (
|
||||
<form>
|
||||
<FieldSelect
|
||||
name="department"
|
||||
control={control}
|
||||
label="Department"
|
||||
data={[{ value: 'IT', label: 'Information Technology' }]}
|
||||
/>
|
||||
|
||||
{/* CRITICAL: We bind the department string to the key prop to force remounts on change */}
|
||||
<FieldSelect
|
||||
key={`role-select-${department}`}
|
||||
name="role"
|
||||
control={control}
|
||||
label="Role"
|
||||
disabled={!department}
|
||||
data={currentRoleOptions}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Form
|
||||
|
||||
Reference in New Issue
Block a user