Form UI Library
Architectural Foundation: React Hook Form v7 · Zod v3 · Mantine v8 · @hookform/resolvers · @mantine/tiptap
Description: 22 pre-built form field components generated via a withRHF() HOC factory, integrating Mantine inputs with React Hook Form micro-subscriptions, Zod validation, and i18n error translation for ERP-scale performance.
Package:
@repo/ui· Module Path:@repo/ui/form> Dependencies: 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 genericsSource: packages/ui/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:
// ✅ 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
└── rich-text.field.tsx # FieldRichTextEditorEach 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-isSetting 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) 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
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' } }),
);
});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
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):
> **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
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>
);
}Object & Async Select Components
Mantine's native Select and MultiSelect are string-based: they store string | null and string[] respectively. In enterprise applications, we often need to store full objects (T | null or T[]) in RHF state — for example, a user object { id: '1', name: 'Alice', email: 'alice@co.com' } rather than just '1'.
The LocalSelect and AsyncSelect engines bridge this gap by:
- Mapping
T[]→ComboboxItem[]for Mantine rendering (viavalueKey+labelKey/renderLabel) - Building an O(1) reverse lookup map (
Map<string, T>) for resolving string changes back to full objects - Intercepting
onChangeto pass resolved objects to RHF
IMPORTANT
These components are separate from the native FieldSelect and FieldMultiSelect, which continue to work as simple string-based Mantine wrappers. Use FieldLocalSelect/FieldAsyncSelect only when you need to store full objects in RHF state.
Single vs. Multi-Select Data Mapping
| Mode | Mantine Component | RHF Value | Mantine value Prop | onChange Payload |
|---|---|---|---|---|
multiple={false} (default) | <Select /> | T | null | string | null | T | null |
multiple={true} | <MultiSelect /> | T[] | string[] | T[] |
FieldLocalSelect — Local Object Select
Accepts a static data array of objects. No async fetching.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
options | T[] | ✅ | Array of objects to select from |
valueKey | keyof T & string | ✅ | Property used as the unique identifier |
labelKey | keyof T & string | — | Property used as the display label |
renderLabel | (item: T) => string | — | Custom label renderer (overrides labelKey) |
multiple | boolean | — | Enable multi-select mode |
filterOption | (item: T, ctx) => boolean | — | Custom filter/exclusion logic |
onSelect | (value: T | T[] | null) => void | — | Side-effect callback on selection change |
name | FieldPath | ✅ | RHF field path |
control | Control | ✅ | RHF control object |
| ...all Mantine Select/MultiSelect props | Passed through to the underlying component |
Usage Example
import { useForm } from 'react-hook-form';
import { FieldLocalSelect } from '@repo/ui/form';
interface Department {
id: string;
name: string;
code: string;
}
const departments: Department[] = [
{ id: '1', name: 'Engineering', code: 'ENG' },
{ id: '2', name: 'Marketing', code: 'MKT' },
{ id: '3', name: 'Finance', code: 'FIN' },
];
function DepartmentForm() {
const { control, handleSubmit } = useForm<{ department: Department | null }>({
defaultValues: { department: null },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data.department))}>
<FieldLocalSelect<Department>
name="department"
control={control}
label="Department"
options={departments}
valueKey="id"
labelKey="name"
searchable
/>
<button type="submit">Submit</button>
</form>
);
}
// On submit: data.department = { id: '1', name: 'Engineering', code: 'ENG' }FieldAsyncSelect — Async Paginated Object Select
Uses Inversion of Control: the component does NOT handle API calls directly. Instead, you provide a loadOptions callback. This supports REST, GraphQL, POST-based search, or any transport.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
loadOptions | LoadOptionsFn<T> | ✅ | Async callback: (search, page, prevOptions) => Promise<{ options: T[], hasMore?: boolean }> |
defaultOptions | T[] | — | Pre-loaded objects always present in dropdown (for edit forms) |
debounceMs | number | — | Search debounce delay (default: 300) |
valueKey | keyof T & string | ✅ | Property used as the unique identifier |
labelKey | keyof T & string | — | Property used as the display label |
renderLabel | (item: T) => string | — | Custom label renderer |
multiple | boolean | — | Enable multi-select mode |
name | FieldPath | ✅ | RHF field path |
control | Control | ✅ | RHF control object |
| ...all Mantine Select/MultiSelect props | Passed through to the underlying component |
Paginated Example
import { useForm } from 'react-hook-form';
import { FieldAsyncSelect, type LoadOptionsFn } from '@repo/ui/form';
import { api } from '@/lib/api';
interface User {
id: string;
fullName: string;
email: string;
}
// The loadOptions callback is completely transport-agnostic
const loadUsers: LoadOptionsFn<User> = async (search, page) => {
const res = await api.get('/users', {
params: { q: search, page, limit: 20 },
});
return {
options: res.data.items,
hasMore: res.data.hasNextPage,
};
};
function UserPickerForm() {
const { control, handleSubmit } = useForm<{ user: User | null }>({
defaultValues: { user: null },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data.user))}>
<FieldAsyncSelect<User>
name="user"
control={control}
label="Assign User"
loadOptions={loadUsers}
valueKey="id"
labelKey="fullName"
placeholder="Search users..."
/>
<button type="submit">Submit</button>
</form>
);
}Non-Paginated Example
If your API returns all results at once, return hasMore: false:
const loadRoles: LoadOptionsFn<Role> = async (search) => {
const roles = await api.get('/roles', { params: { q: search } });
return { options: roles.data, hasMore: false };
};Edit Form with defaultOptions
When editing an existing record, the default value's object may not appear in the first page of API results. Use defaultOptions to inject it:
function EditUserForm({ existingAssignment }: { existingAssignment: User }) {
const { control } = useForm<{ user: User | null }>({
defaultValues: { user: existingAssignment },
});
return (
<FieldAsyncSelect<User>
name="user"
control={control}
label="Reassign User"
loadOptions={loadUsers}
valueKey="id"
labelKey="fullName"
defaultOptions={[existingAssignment]}
/>
);
}Multi-Select Async Example
function TagPickerForm() {
const { control } = useForm<{ tags: Tag[] }>({
defaultValues: { tags: [] },
});
return (
<FieldAsyncSelect<Tag>
multiple
name="tags"
control={control}
label="Tags"
loadOptions={loadTags}
valueKey="id"
renderLabel={(tag) => `${tag.name} (${tag.count})`}
/>
);
}
// On submit: data.tags = [{ id: '1', name: 'React', count: 42 }, ...]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:
// 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)
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)
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
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) | | FieldLocalSelect | Select / MultiSelect | Selection | Stores full T or T[] object in RHF instead of string ID. Accepts static options array with valueKey/labelKey mapping. | | FieldAsyncSelect | Select / MultiSelect | Selection | Async paginated object select with IoC loadOptions callback. Supports search-keyed caching, defaultOptions for edit forms, and automatic pagination detection. | | FieldFileInput | <FileInput /> | File | File[] | null | | FieldRichTextEditor | @mantine/tiptap | string (HTML) |
Rich Text Editor (TipTap)
The FieldRichTextEditor component integrates @mantine/tiptap directly with React Hook Form. It safely stores the Editor's HTML output directly into the RHF state as a string. Because TipTap is an uncontrolled editor natively, this field uses a specialized useController wrapper that automatically syncs bidirectional updates (e.g., calling editor.commands.setContent(field.value) when the form is reset or async default values arrive).
Testing
Tests are located in src/components/Form/__tests__/ and can be run via:
cd packages/ui && pnpm testThe 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.