diff --git a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx index df010e3..9ec0c3e 100644 --- a/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx +++ b/apps/web/src/apps/showcase/example/features/form-demo/components/reactive-watch-demo.tsx @@ -28,24 +28,30 @@ export default function ReactiveWatchDemo() { department: z.string().optional(), role: z.string().optional(), }) - .superRefine((data, ctx) => { - if (data.userType === 'CORPORATE') { - const res = taxIdValidator.safeParse(data.corporateTaxId || ''); - if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['corporateTaxId'] })); - } - if (data.hasSpouse) { - const res = spouseNameValidator.safeParse(data.spouseName || ''); - if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['spouseName'] })); - } - if (data.newsletter) { - const res = newsletterEmailValidator.safeParse(data.newsletterEmail || ''); - if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['newsletterEmail'] })); - } - if (data.department) { - const res = roleValidator.safeParse(data.role || ''); - if (!res.success) res.error.issues.forEach((i: any) => ctx.addIssue({ ...i, path: ['role'] })); - } - }); + .and( + z.discriminatedUnion('userType', [ + z.object({ userType: z.literal('PERSONAL') }), + z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }) + ]) + ) + .and( + z.union([ + z.object({ hasSpouse: z.literal(false) }), + z.object({ hasSpouse: z.literal(true), spouseName: spouseNameValidator }) + ]) + ) + .and( + z.union([ + z.object({ newsletter: z.literal(false) }), + z.object({ newsletter: z.literal(true), newsletterEmail: newsletterEmailValidator }) + ]) + ) + .and( + z.union([ + z.object({ department: z.string().min(1), role: roleValidator }), + z.object({ department: z.union([z.literal(''), z.undefined(), z.null()]).optional() }) + ]) + ); const { control, handleSubmit, setValue, unregister, clearErrors } = useForm({ resolver: zodResolver(reactiveSchema as any), diff --git a/packages/ui/docs/FORM-COMPONENTS.md b/packages/ui/docs/FORM-COMPONENTS.md index 6c7c558..dcbc0f1 100644 --- a/packages/ui/docs/FORM-COMPONENTS.md +++ b/packages/ui/docs/FORM-COMPONENTS.md @@ -479,6 +479,77 @@ export function DepartmentForm() { --- +## Enterprise Performance Guidelines: Forms & Validation + +When building large-scale ERP forms, seemingly trivial React or Zod patterns can catastrophically degrade performance at scale. Adhere strictly to the following optimizations. + +### The "Unstable Default Value" Trap in Hooks + +When creating custom form hooks (like `useConditionalField`), you often need to provide a fallback or default value. Passing an inline array or object as a `defaultValue` can trigger infinite render loops if it is included in a `useEffect` dependency array, because React's referential equality check fails on every render. + +**Solution: The `useRef` Stabilization Pattern** + +We resolve this by storing the `defaultValue` in a `useRef`. This allows the hook's cleanup logic to access the latest value without triggering the effect again: + +```tsx +// Inside useConditionalField.ts +const defaultValueRef = useRef(defaultValue); + +// Update ref on every render without triggering dependencies +useEffect(() => { + defaultValueRef.current = defaultValue; +}, [defaultValue]); + +// The main effect no longer depends on defaultValue +useEffect(() => { + if (!condition) { + const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : ''; + setValue(name, targetValue); + } +}, [condition, name, setValue]); +``` + +### Zod Schema Performance: Avoid superRefine for Conditionals + +For complex dynamic forms, developers often default to `.superRefine` or `.refine` to handle conditional validation (e.g., "Require Tax ID only if userType is Corporate"). + +**The Problem:** `superRefine` acts as an opaque callback. Zod cannot optimize it. In large forms, doing manual `.safeParse` inside a `superRefine` loop forces Zod to parse the entire tree continuously on every keystroke, leading to severe O(n) CPU spikes. + +**The Solution:** Use declarative schema branching via `.and()`, `z.discriminatedUnion`, and `z.union`. These are statically analyzed by Zod and evaluated at native speed. + +#### ❌ Bad: Manual Parsing (O(n) CPU Spike) + +```tsx +const badSchema = z.object({ + userType: z.enum(['PERSONAL', 'CORPORATE']), + corporateTaxId: z.string().optional() +}).superRefine((data, ctx) => { + if (data.userType === 'CORPORATE') { + // ⚠️ INCREDIBLY SLOW: Manual parsing inside refine loop + const res = taxIdValidator.safeParse(data.corporateTaxId); + if (!res.success) ctx.addIssue({ ...res.error.issues[0], path: ['corporateTaxId'] }); + } +}); +``` + +#### ✅ Good: Declarative Unions (O(1) Evaluation) + +```tsx +const goodSchema = z.object({ + userType: z.enum(['PERSONAL', 'CORPORATE']), + corporateTaxId: z.string().optional() +}).and( + z.discriminatedUnion('userType', [ + z.object({ userType: z.literal('PERSONAL') }), + z.object({ userType: z.literal('CORPORATE'), corporateTaxId: taxIdValidator }) + ]) +); +``` + +By stacking `.and(z.union([...]))` for independent conditionals (like `hasSpouse`, `newsletter`, etc.), you achieve lightning-fast, type-safe conditional validation without writing a single `superRefine` loop. + +--- + ## Usage Examples ### Basic Form diff --git a/packages/ui/src/hooks/useConditionalField.ts b/packages/ui/src/hooks/useConditionalField.ts index d05a511..9d3ba3f 100644 --- a/packages/ui/src/hooks/useConditionalField.ts +++ b/packages/ui/src/hooks/useConditionalField.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { UseFormSetValue, UseFormUnregister, UseFormClearErrors, FieldValues, Path } from 'react-hook-form'; export interface UseConditionalFieldOptions { @@ -33,12 +33,19 @@ export function useConditionalField( const config = options; + // Stabilize defaultValue using useRef to prevent infinite render loops + // if developers pass inline arrays/objects (e.g. defaultValue: []) + const defaultValueRef = useRef(defaultValue); + useEffect(() => { + defaultValueRef.current = defaultValue; + }, [defaultValue]); + 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); + const targetValue = defaultValueRef.current !== undefined ? defaultValueRef.current : ('' as any); config.setValue(config.name, targetValue, { shouldDirty: true, shouldTouch: true, @@ -63,7 +70,6 @@ export function useConditionalField( setValue, unregister, clearErrors, - defaultValue, mode ]); } \ No newline at end of file