19 KiB
Form UI Library — Architecture & Usage Guide
Package:
@repo/ui· Module Path:@repo/ui/formDependencies: React Hook Form v7, Zod v3, Mantine v8,@repo/core-i18n
Table of Contents
- Overview
- Architecture
- Performance & Memoization
- i18n Error Translation
- Theme & Style Inheritance
- Validation Layer
- Usage Examples
- Component Reference
- Testing
Overview
The Form UI Library provides 22 pre-built form field components that integrate Mantine v8 form components with React Hook Form (RHF) and Zod 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<MantineComponentProps>(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
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:
// ✅ 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:
// fields/text-input.field.tsx
import { TextInput, type TextInputProps } from '@mantine/core';
import { withRHF } from '../withRHF';
export const FieldTextInput = withRHF<TextInputProps>('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)
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:
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:
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:
// 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:
ThemeProvider— WrapsMantineProviderwith brand colors, density tokens, and color scheme- Density tokens —
compactDensity/standardDensityset defaultsizeprops on all inputs (e.g.,TextInput: { defaultProps: { size: 'sm' } }) - Color scheme —
forceColorSchemeonMantineProviderhandles dark/light mode - CSS variables —
theme.cssmaps Mantine CSS variables to Tailwind tokens
This means:
// The FieldTextInput inherits compact sizing, brand colors, and dark mode
// automatically — no additional configuration needed.
<ThemeProvider colorScheme="dark" density="compact">
<form>
<FieldTextInput name="email" control={control} label="Email" />
</form>
</ThemeProvider>
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
// 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<typeof sampleValidator>;
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.
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<SampleValidatorType>({
resolver: zodResolver(sampleValidator),
defaultValues: { email: '', name: '' },
});
const onSubmit: SubmitHandler<SampleValidatorType> = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="email" control={control} label="Email" />
<FieldTextInput name="name" control={control} label="Name" />
<button type="submit">Submit</button>
</form>
);
}
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) andrangeLength(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
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:
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' } })
);
});
Usage Examples
Basic Form
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<LoginForm>({
defaultValues: { email: '', password: '' },
});
const onSubmit: SubmitHandler<LoginForm> = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="email" control={control} label="Email" />
<FieldPasswordInput name="password" control={control} label="Password" />
<button type="submit">Login</button>
</form>
);
}
With Zod Validation
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<typeof productSchema>;
function ProductEditor() {
const { control, handleSubmit } = useForm<ProductForm>({
resolver: zodResolver(productSchema),
defaultValues: {
name: '',
sku: '',
price: 0,
category: '',
isActive: true,
},
});
const onSubmit: SubmitHandler<ProductForm> = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FieldTextInput name="name" control={control} label="Product Name" />
<FieldTextInput name="sku" control={control} label="SKU" placeholder="ABC-1234" />
<FieldNumberInput name="price" control={control} label="Price" min={0} prefix="$" />
<FieldSelect
name="category"
control={control}
label="Category"
data={['Electronics', 'Clothing', 'Food']}
/>
<FieldCheckbox name="isActive" control={control} label="Active" />
<button type="submit">Save Product</button>
</form>
);
}
Custom Field Component
Use withRHF directly to wrap any Mantine component not included in the library:
import { DatePickerInput, type DatePickerInputProps } from '@mantine/dates';
import { withRHF } from '@repo/ui/form';
export const FieldDatePicker = withRHF<DatePickerInputProps>(
'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 <select> element |
FieldTagsInput |
TagsInput |
Selection | Free-form tag entry |
FieldCheckbox |
Checkbox |
Toggle | Boolean checkbox (uses checked) |
FieldRadioGroup |
Radio.Group |
Toggle | Radio button group |
FieldSwitch |
Switch |
Toggle | Boolean switch (uses checked) |
FieldChipGroup |
Chip.Group |
Toggle | Chip selection group (uses Input.Wrapper) |
FieldSegmentedControl |
SegmentedControl |
Toggle | Segmented control (uses Input.Wrapper) |
FieldSlider |
Slider |
Range | Single-value slider |
FieldRangeSlider |
RangeSlider |
Range | Dual-handle range slider |
FieldRating |
Rating |
Range | Star rating |
FieldColorInput |
ColorInput |
Color | Color picker with text input |
FieldColorPicker |
ColorPicker |
Color | Color picker only (uses Input.Wrapper) |
FieldFileInput |
FileInput |
File | File upload input |
Testing
Tests are located in src/components/Form/__tests__/ and can be run via:
cd packages/ui && pnpm test
The test suite covers:
withRHF.test.tsx(8 tests) — Core HOC behavior: rendering, value binding, input mutation, error display, i18n translation, fallback behavior, displayName, prop forwardingtext-input.field.test.tsx(4 tests) — FieldTextInput integration with Zod validation, error display/clearing, and full submission flowcheckbox.field.test.tsx(4 tests) — FieldCheckbox boolean toggle, checked state, RHF submission, and Zod required validation
All tests use @testing-library/react with mocked @repo/core-i18n and a window.matchMedia polyfill for jsdom compatibility with Mantine v8.