feat: add useConditionalField hook and implement reactive form showcase examples

This commit is contained in:
Firman Ramdhani
2026-06-15 22:03:11 +07:00
parent f7c7bc6907
commit 50cce32ada
14 changed files with 862 additions and 172 deletions
+117
View File
@@ -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
View File
@@ -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 ─────────────────────────────────────────────────────────────