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
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from '@mantine/hooks';
|
||||
export * from './useConditionalField';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
export interface UseConditionalFieldOptions<TFieldValues extends FieldValues> {
|
||||
condition: boolean;
|
||||
name: Path<TFieldValues>;
|
||||
setValue: UseFormSetValue<TFieldValues>;
|
||||
unregister?: UseFormUnregister<TFieldValues>;
|
||||
clearErrors?: UseFormClearErrors<TFieldValues>;
|
||||
defaultValue?: any;
|
||||
mode?: 'unregister' | 'reset';
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically cleans up a conditionally rendered React Hook Form field
|
||||
* when its parent condition becomes false.
|
||||
*
|
||||
* @param options Configuration object for the conditional field behavior
|
||||
*/
|
||||
export function useConditionalField<TFieldValues extends FieldValues>(
|
||||
options: UseConditionalFieldOptions<TFieldValues>
|
||||
) {
|
||||
// Destructure with default values
|
||||
const {
|
||||
condition,
|
||||
name,
|
||||
setValue,
|
||||
unregister,
|
||||
clearErrors,
|
||||
defaultValue,
|
||||
mode = 'unregister'
|
||||
} = options;
|
||||
|
||||
const config = options;
|
||||
|
||||
useEffect(() => {
|
||||
// When the condition evaluates to false, we execute the cleanup logic
|
||||
if (!condition) {
|
||||
// 1. Reset the field value. We use a stable empty state (like '' instead of undefined)
|
||||
// to prevent uncontrolled component fallback in the React UI. We also force RHF to sync.
|
||||
const targetValue = config.defaultValue !== undefined ? config.defaultValue : ('' as any);
|
||||
config.setValue(config.name, targetValue, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
// 2. Execute the appropriate side-effect strategy based on the active mode
|
||||
if (mode === 'unregister' && config.unregister) {
|
||||
// Unregister Mode: Completely unmounts the field from React Hook Form.
|
||||
// The value is removed from the payload and validation is entirely bypassed.
|
||||
config.unregister(config.name);
|
||||
} else if (mode === 'reset' && config.clearErrors) {
|
||||
// Reset Mode: Keeps the field active in the DOM (e.g. cascading or disabled dependencies).
|
||||
// Wipes the value and clears active validation errors so the user can interact
|
||||
// with a fresh state, but keeps the property in the payload.
|
||||
config.clearErrors(config.name);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
condition,
|
||||
name,
|
||||
setValue,
|
||||
unregister,
|
||||
clearErrors,
|
||||
defaultValue,
|
||||
mode
|
||||
]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z, type ZodString, type ZodNumber, type ZodTypeAny } from 'zod';
|
||||
import type { ZodString, ZodNumber, ZodTypeAny } from 'zod';
|
||||
|
||||
// ─── UTILITIES ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user