# Form UI Library — Architecture & Usage Guide > **Package**: `@repo/ui` · **Module Path**: `@repo/ui/form` > **Dependencies**: React Hook Form v7, Zod v3, Mantine v8, `@repo/core-i18n` --- ## Table of Contents - [Overview](#overview) - [Architecture](#architecture) - [HOC Factory Pattern](#hoc-factory-pattern) - [Naming Conventions](#naming-conventions) - [File Structure](#file-structure) - [Performance & Memoization](#performance--memoization) - [i18n Error Translation](#i18n-error-translation) - [Theme & Style Inheritance](#theme--style-inheritance) - [Validation Layer](#validation-layer) - [Usage Examples](#usage-examples) - [Basic Form](#basic-form) - [With Zod Validation](#with-zod-validation) - [Custom Field Component](#custom-field-component) - [Component Reference](#component-reference) - [Testing](#testing) --- ## Overview The Form UI Library provides **22 pre-built form field components** that integrate [Mantine v8](https://mantine.dev/) form components with [React Hook Form (RHF)](https://react-hook-form.com/) and [Zod](https://zod.dev/) validation. Each component is generated via a central `withRHF()` HOC factory, ensuring consistent behavior across: - **Value binding** — Two-way data flow between RHF and Mantine - **Error display** — Automatic rendering of validation errors - **i18n translation** — Zod errors can be encoded as JSON payloads for translation - **Performance** — Micro-subscriptions via `useController` + `React.memo` - **Theme compliance** — Zero hardcoded styles; all styling flows from the existing `ThemeProvider` --- ## Architecture ### HOC Factory Pattern The entire library is built on a single factory function: ``` withRHF(displayName, MantineComponent, options?) └─► Returns a React.memo'd component that: ├── Uses useController() for field-level subscriptions ├── Maps field.value/onChange/onBlur to Mantine props ├── Intercepts fieldState.error?.message │ ├── Attempts JSON.parse for i18n payloads │ └── Falls back to raw string if not translatable ├── Passes error={translated} to Mantine component ├── Forwards ref to the underlying DOM element └── Preserves full Mantine TypeScript generics ``` **Source**: [`withRHF.tsx`](../src/components/Form/withRHF.tsx) The factory accepts three arguments: | Argument | Type | Description | |---|---|---| | `displayName` | `string` | React DevTools name (e.g., `"FieldTextInput"`) | | `MantineComponent` | `ComponentType` | The raw Mantine component | | `options` | `WithRHFOptions` | Optional config for special components | #### Options | Option | Default | Description | |---|---|---| | `isCheckType` | `false` | Use `checked` instead of `value` (for Checkbox, Switch) | | `requiresWrapper` | `false` | Wrap in `Input.Wrapper` for error display (for ColorPicker, SegmentedControl, Chip.Group) | ### Naming Conventions All wrapped components use the **`Field` prefix** to prevent naming collisions with native Mantine exports: ```tsx // ✅ Our library — RHF-connected, type-safe import { FieldTextInput } from '@repo/ui/form'; // ✅ Native Mantine — still accessible via the same package import { TextInput } from '@repo/ui/components'; ``` This avoids ambiguity in large codebases where both raw Mantine and form-connected versions might be needed. ### File Structure Each component lives in its own file following the `[kebab-case-name].field.tsx` convention within the `fields/` directory: ``` packages/ui/src/components/Form/ ├── withRHF.tsx # HOC factory ├── types.ts # Shared TypeScript types ├── index.ts # Barrel exports ├── __tests__/ │ ├── withRHF.test.tsx │ ├── text-input.field.test.tsx │ └── checkbox.field.test.tsx └── fields/ ├── text-input.field.tsx # FieldTextInput ├── password-input.field.tsx # FieldPasswordInput ├── textarea.field.tsx # FieldTextarea ├── number-input.field.tsx # FieldNumberInput ├── select.field.tsx # FieldSelect ├── multi-select.field.tsx # FieldMultiSelect ├── native-select.field.tsx # FieldNativeSelect ├── checkbox.field.tsx # FieldCheckbox ├── radio-group.field.tsx # FieldRadioGroup ├── switch.field.tsx # FieldSwitch ├── slider.field.tsx # FieldSlider ├── range-slider.field.tsx # FieldRangeSlider ├── rating.field.tsx # FieldRating ├── color-input.field.tsx # FieldColorInput ├── color-picker.field.tsx # FieldColorPicker ├── pin-input.field.tsx # FieldPinInput ├── json-input.field.tsx # FieldJsonInput ├── autocomplete.field.tsx # FieldAutocomplete ├── tags-input.field.tsx # FieldTagsInput ├── chip-group.field.tsx # FieldChipGroup ├── segmented-control.field.tsx # FieldSegmentedControl └── file-input.field.tsx # FieldFileInput ``` Each field file is a thin one-liner: ```tsx // fields/text-input.field.tsx import { TextInput, type TextInputProps } from '@mantine/core'; import { withRHF } from '../withRHF'; export const FieldTextInput = withRHF('FieldTextInput', TextInput); ``` --- ## Performance & Memoization ### Why `React.memo` + `useController`? In enterprise ERP forms with **1500+ fields**, performance is critical: | Technique | What it prevents | Cost | |---|---|---| | **`useController`** | Global form state re-renders — each field subscribes only to its own slice | ~0 (hook-level isolation) | | **`React.memo`** | Parent-driven re-renders (e.g., grid layout changes, tab switches) | O(n) shallow prop comparison (typically n < 10) | Together, they achieve **O(1) render cost per keystroke** regardless of form size. ### When `React.memo` is NOT needed For simple forms (< 50 fields), `React.memo` adds negligible overhead but provides no measurable benefit. However, since the HOC is used across the entire organization, the default-on strategy ensures correctness at scale without requiring per-form tuning. --- ## i18n Error Translation The HOC supports three error message formats: ### 1. Plain String (default Zod behavior) ```tsx const schema = z.object({ name: z.string().min(1, 'Name is required'), }); // Error displayed: "Name is required" ``` ### 2. JSON i18n Payload (structured translation) Encode Zod errors as JSON with a translation key: ```tsx const schema = z.object({ name: z.string().min(3, JSON.stringify({ key: 'validation:min_length', values: { min: 3 }, })), }); // Error displayed: t('validation:min_length', { min: 3 }) // → "Minimum 3 characters" (from validation namespace) ``` ### 3. Translation Key String If the raw error string matches a key in the `validation` namespace: ```tsx const schema = z.object({ email: z.string().email('validation:invalid_email'), }); // Error displayed: t('validation:invalid_email') // → "Please enter a valid email address" ``` ### Translation Resolution Chain ``` error.message ├── JSON.parse → { key, values } │ ├── t(key, { ...values, ns: 'validation' }) → translated ✓ │ └── t(key, { ...values, ns: 'common' }) → translated ✓ │ └── raw error.message (fallback) → displayed as-is ├── i18n.exists(message, { ns: 'validation' }) │ └── t(message, { ns: 'validation' }) → translated ✓ └── raw string → displayed as-is ``` ### Setting up the `validation` namespace Add validation translations to your locale files: ```json // packages/core-i18n/src/locales/en/validation.json { "validation": { "required": "This field is required", "min_length": "Minimum {{min}} characters", "max_length": "Maximum {{max}} characters", "invalid_email": "Please enter a valid email address" } } ``` --- ## Theme & Style Inheritance The Form components **do NOT hardcode any styles**. All visual appearance flows from: 1. **`ThemeProvider`** — Wraps `MantineProvider` with brand colors, density tokens, and color scheme 2. **Density tokens** — `compactDensity` / `standardDensity` set default `size` props on all inputs (e.g., `TextInput: { defaultProps: { size: 'sm' } }`) 3. **Color scheme** — `forceColorScheme` on `MantineProvider` handles dark/light mode 4. **CSS variables** — `theme.css` maps Mantine CSS variables to Tailwind tokens This means: ```tsx // The FieldTextInput inherits compact sizing, brand colors, and dark mode // automatically — no additional configuration needed.
``` --- ## Validation Layer To prevent over-engineering and package fatigue, we house the validation layer directly inside the UI package at `packages/ui/src/validators` rather than creating a separate `@repo/validation` package. This layer defines centralized Zod schemas that are pre-configured to output JSON-stringified i18n payloads. ### Writing a Centralized Validator ```tsx // packages/ui/src/validators/sample.validator.ts import { z } from 'zod'; import { compose, emailValidator, minLength } from './registry.validator'; export const sampleValidator = z.object({ email: compose(z.string(), emailValidator()), name: compose(z.string(), minLength(3, 'Nama')), }); export type SampleValidatorType = z.infer; ``` ### Applying the Validator When consuming these validators, use the `zodResolver` exported from `@repo/ui/form` and the validator from `@repo/ui/validators`. The Form components will automatically intercept the JSON payload, translate it using the `validation` namespace, and display the correct language to the user. ```tsx import { useForm, type SubmitHandler } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { FieldTextInput } from '@repo/ui/form'; import { sampleValidator, type SampleValidatorType } from '@repo/ui/validators'; function ExampleForm() { const { control, handleSubmit } = useForm({ resolver: zodResolver(sampleValidator), defaultValues: { email: '', name: '' }, }); const onSubmit: SubmitHandler = (data) => console.log(data); return (
); } ``` --- ## Validator Bank Reference The `registry.validator.ts` provides a set of pre-configured atomic validators returning modified Zod schemas that automatically emit translated JSON payloads. ### Available Atomic Validators | Category | Validator | Target Type | Description | |---|---|---|---| | **Numeric** | `minValue(min, field?)` | `ZodNumber` | Minimum numeric value | | **Numeric** | `maxValue(max, field?)` | `ZodNumber` | Maximum numeric value | | **Numeric** | `rangeValue(min, max, field?)` | `ZodNumber` | Restricts value between `min` and `max` limits | | **Numeric** | `positiveNumber(field?)` | `ZodNumber` | Restricts to positive numbers | | **String** | `minLength(len, field?)` | `ZodString` | Minimum string character length | | **String** | `maxLength(len, field?)` | `ZodString` | Maximum string character length | | **String** | `rangeLength(min, max, field?)` | `ZodString` | Restricts string length between `min` and `max` bounds | | **Security** | `simplePassword(min)` | `ZodString` | Checks password string length bounds only | | **Security** | `complexPassword(min)` | `ZodString` | Enforces length, 1 uppercase, 1 lowercase, 1 number, and 1 special char | | **Technical** | `emailValidator()` | `ZodString` | Standard email format | | **Technical** | `phoneValidator()` | `ZodString` | Enforces Indonesian (+62) phone number format | > [!WARNING] > Always distinguish between `rangeValue` (which bounds the actual numeric integer/float) and `rangeLength` (which bounds the amount of characters in a string). ### Composition Guide Instead of manually chaining long `.min().max().regex()` methods, use the `compose()` helper utility to elegantly stack atomic validators onto a base primitive. **Example: User Registration Password Field** ```tsx import { z } from 'zod'; import { compose, required, minLength, complexPassword } from '@repo/ui/validators'; export const userRegistrationSchema = z.object({ password: compose( z.string(), required('Password'), complexPassword(8) ) }); ``` ### Testing Validators We enforce strict test coverage for our Validation Bank. If you add a new atomic validator to `registry.validator.ts`, you MUST add corresponding tests to `__tests__/registry.validator.test.ts`. Tests must explicitly verify the JSON stringified i18n payload: ```typescript it('minValue() should enforce min', () => { const schema = compose(z.number(), minValue(10, 'Age')); const res = schema.safeParse(5); expect(res.success).toBe(false); expect(res.error?.issues[0].message).toBe( JSON.stringify({ key: 'validation:min_val', values: { min: 10, field: 'Age' } }) ); }); ``` --- ## 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
...
; } ``` ### 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 (
{/* CRITICAL: We bind the department string to the key prop to force remounts on change */} ); } ``` --- ## 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 ```tsx import { useForm, type SubmitHandler } from 'react-hook-form'; import { FieldTextInput, FieldPasswordInput } from '@repo/ui/form'; type LoginForm = { email: string; password: string }; function LoginForm() { const { control, handleSubmit } = useForm({ defaultValues: { email: '', password: '' }, }); const onSubmit: SubmitHandler = (data) => { console.log(data); }; return (
); } ``` ### With Zod Validation ```tsx import { z } from 'zod'; import { useForm, type SubmitHandler } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { FieldTextInput, FieldNumberInput, FieldSelect, FieldCheckbox, } from '@repo/ui/form'; const productSchema = z.object({ name: z.string().min(1, { message: JSON.stringify({ key: 'validation:required', values: { field: 'Product Name' } }) }), sku: z.string().regex(/^[A-Z]{3}-\d{4}$/, { message: JSON.stringify({ key: 'validation:invalid_format', values: { format: 'AAA-0000' } }) }), price: z.number().min(0, { message: JSON.stringify({ key: 'validation:min_value', values: { min: 0 } }) }), category: z.string().min(1, { message: JSON.stringify({ key: 'validation:required', values: { field: 'Category' } }) }), isActive: z.boolean(), }); type ProductForm = z.infer; function ProductEditor() { const { control, handleSubmit } = useForm({ resolver: zodResolver(productSchema), defaultValues: { name: '', sku: '', price: 0, category: '', isActive: true, }, }); const onSubmit: SubmitHandler = (data) => console.log(data); return (
); } ``` ### Custom Field Component Use `withRHF` directly to wrap any Mantine component not included in the library: ```tsx import { DatePickerInput, type DatePickerInputProps } from '@mantine/dates'; import { withRHF } from '@repo/ui/form'; export const FieldDatePicker = withRHF( 'FieldDatePicker', DatePickerInput, ); ``` --- ## Component Reference | Component | Mantine Source | Type | Notes | |---|---|---|---| | `FieldTextInput` | `TextInput` | Text | Standard text input | | `FieldPasswordInput` | `PasswordInput` | Text | Password with visibility toggle | | `FieldTextarea` | `Textarea` | Text | Multi-line text | | `FieldNumberInput` | `NumberInput` | Text | Numeric with increment/decrement | | `FieldJsonInput` | `JsonInput` | Text | JSON-formatted text | | `FieldPinInput` | `PinInput` | Text | PIN/OTP code input | | `FieldAutocomplete` | `Autocomplete` | Text | Text input with suggestions | | `FieldSelect` | `Select` | Selection | Single-value dropdown | | `FieldMultiSelect` | `MultiSelect` | Selection | Multi-value dropdown | | `FieldNativeSelect` | `NativeSelect` | Selection | Native `