Reusable Forms
Creating reusable nested form components and working with arrays.
Overviewβ
Reusable form components:
- Encapsulate a group of related fields
- Can be used multiple times in different contexts
- Accept a
controlprop typed to their specific structure
This pattern is essential for:
- Reducing code duplication
- Ensuring consistent field layouts
- Managing arrays of complex objects
How Nested Forms Workβ
The nested forms pattern consists of three parts:
- Props interface β defines
controltype viaGroupNodeWithControls<T> - Component β responsible only for field layout using
FormField - Memoization β wrap in
memo()to prevent unnecessary re-renders
// 1. Props interface
interface MyFormProps {
control: GroupNodeWithControls<MyType>;
}
// 2. Component
const MyFormComponent = ({ control }: MyFormProps) => {
return (
<div className="space-y-4">
<FormField control={control.field1} />
<FormField control={control.field2} />
</div>
);
};
// 3. Memoization
export const MyForm = memo(MyFormComponent);
Using a Nested Formβ
A nested form is used in the parent component by passing control:
import { MyForm } from './sub-forms/MyForm';
export function ParentForm({ control }: ParentFormProps) {
return (
<div className="space-y-6">
<h3>Section 1</h3>
<MyForm control={control.section1} />
<h3>Section 2</h3>
<MyForm control={control.section2} />
</div>
);
}
Where to Use Nested Formsβ
- Addresses β registration, residence, delivery
- Personal data β for borrower, co-borrower, contact person
- Documents β passport, license, ID
- Repeating blocks β properties, loans, income sources
Tutorial Form Implementationsβ
All forms are located in reformer-tutorial/src/forms/credit-application/sub-forms/.
AddressFormβ
Address form β region, city, street, house, apartment, postal code.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { Address } from '../types/credit-application.types';
interface AddressFormProps {
control: GroupNodeWithControls<Address>;
}
const AddressFormComponent = ({ control }: AddressFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField control={control.region} />
<FormField control={control.city} />
</div>
<FormField control={control.street} />
<div className="grid grid-cols-3 gap-4">
<FormField control={control.house} />
<FormField control={control.apartment} />
<FormField control={control.postalCode} />
</div>
</div>
);
};
export const AddressForm = memo(AddressFormComponent);
PersonalDataFormβ
Personal data β full name, birth date, birth place, gender.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { PersonalData } from '../types/credit-application.types';
interface PersonalDataFormProps {
control: GroupNodeWithControls<PersonalData>;
}
const PersonalDataFormComponent = ({ control }: PersonalDataFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<FormField control={control.lastName} />
<FormField control={control.firstName} />
<FormField control={control.middleName} />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField control={control.birthDate} />
<FormField control={control.birthPlace} />
</div>
<FormField control={control.gender} />
</div>
);
};
export const PersonalDataForm = memo(PersonalDataFormComponent);
PassportDataFormβ
Passport data β series, number, issue date, department code, issued by.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { PassportData } from '../types/credit-application.types';
interface PassportDataFormProps {
control: GroupNodeWithControls<PassportData>;
}
const PassportDataFormComponent = ({ control }: PassportDataFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField control={control.series} />
<FormField control={control.number} />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField control={control.issueDate} />
<FormField control={control.departmentCode} />
</div>
<FormField control={control.issuedBy} />
</div>
);
};
export const PassportDataForm = memo(PassportDataFormComponent);
CoBorrowerFormβ
Co-borrower data β personal data, phone, email, relationship, income.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { CoBorrower } from '../types/credit-application.types';
interface CoBorrowerFormProps {
control: GroupNodeWithControls<CoBorrower>;
}
const CoBorrowerFormComponent = ({ control }: CoBorrowerFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<FormField control={control.personalData.lastName} />
<FormField control={control.personalData.firstName} />
<FormField control={control.personalData.middleName} />
</div>
<FormField control={control.personalData.birthDate} />
<div className="grid grid-cols-2 gap-4">
<FormField control={control.phone} />
<FormField control={control.email} />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField control={control.relationship} />
<FormField control={control.monthlyIncome} />
</div>
</div>
);
};
export const CoBorrowerForm = memo(CoBorrowerFormComponent);
PropertyFormβ
Property information β type, value, description, encumbrance.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { Property } from '../types/credit-application.types';
interface PropertyFormProps {
control: GroupNodeWithControls<Property>;
}
const PropertyFormComponent = ({ control }: PropertyFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField control={control.type} />
<FormField control={control.estimatedValue} />
</div>
<FormField control={control.description} />
<FormField control={control.hasEncumbrance} />
</div>
);
};
export const PropertyForm = memo(PropertyFormComponent);
ExistingLoanFormβ
Existing loans β bank, type, amount, remaining, payment, maturity date.
import { memo } from 'react';
import type { GroupNodeWithControls } from '@reformer/core';
import { FormField } from '@/components/ui/FormField';
import type { ExistingLoan } from '../types/credit-application.types';
interface ExistingLoanFormProps {
control: GroupNodeWithControls<ExistingLoan>;
}
const ExistingLoanFormComponent = ({ control }: ExistingLoanFormProps) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField control={control.bank} />
<FormField control={control.type} />
</div>
<div className="grid grid-cols-3 gap-4">
<FormField control={control.amount} />
<FormField control={control.remainingAmount} />
<FormField control={control.monthlyPayment} />
</div>
<FormField control={control.maturityDate} />
</div>
);
};
export const ExistingLoanForm = memo(ExistingLoanFormComponent);
Working with Arraysβ
Array Operationsβ
ArrayNodeWithControls provides the following operations:
| Method | Description |
|---|---|
push() | Add element with default values from schema |
removeAt(index) | Remove element by index |
map(callback) | Iterate over array elements |
length | Get current array length |
FormArray from @reformer/uiβ
The @reformer/ui package provides FormArray β a headless compound component for managing form arrays:
npm install @reformer/ui
Basic Usageβ
import { FormArray } from '@reformer/ui/form-array';
import { Button } from '@/components/ui/button';
<FormArray.Root control={form.items}>
<FormArray.Empty>
<p>No items yet</p>
</FormArray.Empty>
<FormArray.List>
{({ control, index, remove }) => (
<div key={control.id}>
<h4>Item #{index + 1}</h4>
<ItemForm control={control} />
<button onClick={remove}>Remove</button>
</div>
)}
</FormArray.List>
<FormArray.AddButton>Add item</FormArray.AddButton>
</FormArray.Root>
Sub-componentsβ
| Component | Props | Purpose |
|---|---|---|
FormArray.Root | control: ArrayNode<T> | Context provider |
FormArray.List | children: (item) => Node | Iteration with render props |
FormArray.AddButton | initialValue?: Partial<T> | Add new element |
FormArray.Empty | children: ReactNode | Show when array is empty |
FormArray.Count | render?: (count) => Node | Display count |
Render Props in Listβ
interface FormArrayItemRenderProps<T> {
control: GroupNodeWithControls<T>; // Item control
index: number; // Index (from 0)
id: string | number; // Unique key
remove: () => void; // Remove this item
}
Using FormArray in AdditionalInfoFormβ
import { useFormControlValue } from '@reformer/core';
import { FormArray } from '@reformer/ui/form-array';
import { FormField } from '@/components/ui/FormField';
import { PropertyForm } from '../sub-forms/property/PropertyForm';
import { CoBorrowerForm } from '../sub-forms/co-borrower/CoBorrowerForm';
import { Button } from '@/components/ui/button';
export function AdditionalInfoForm({ control }: AdditionalInfoFormProps) {
const hasProperty = useFormControlValue(control.hasProperty);
const hasCoBorrower = useFormControlValue(control.hasCoBorrower);
return (
<div className="space-y-6">
<FormField control={control.hasProperty} />
{hasProperty && (
<FormArray.Root control={control.properties}>
<div className="flex justify-between items-center">
<FormArray.Count render={(count) => (
<span className="text-sm text-muted-foreground">{count} Property</span>
)} />
<FormArray.AddButton asChild>
<Button type="button" variant="outline" size="sm">
+ Add property
</Button>
</FormArray.AddButton>
</div>
<FormArray.List>
{({ control: itemControl, index, remove }) => (
<div className="p-4 bg-white rounded-lg border shadow-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="font-medium">Property #{index + 1}</h4>
<Button variant="destructive" size="sm" onClick={remove}>
Remove
</Button>
</div>
<PropertyForm control={itemControl} />
</div>
)}
</FormArray.List>
<FormArray.Empty>
<div className="p-6 bg-gray-50 border-dashed border rounded-lg text-center text-gray-500">
No property. Click the button above to add.
</div>
</FormArray.Empty>
</FormArray.Root>
)}
<FormField control={control.hasCoBorrower} />
{hasCoBorrower && (
<FormArray.Root control={control.coBorrowers}>
<div className="flex justify-between items-center">
<FormArray.Count render={(count) => (
<span className="text-sm text-muted-foreground">{count} Co-borrowers</span>
)} />
<FormArray.AddButton asChild>
<Button type="button" variant="outline" size="sm">
+ Add co-borrower
</Button>
</FormArray.AddButton>
</div>
<FormArray.List>
{({ control: itemControl, index, remove }) => (
<div className="p-4 bg-white rounded-lg border shadow-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="font-medium">Co-borrower #{index + 1}</h4>
<Button variant="destructive" size="sm" onClick={remove}>
Remove
</Button>
</div>
<CoBorrowerForm control={itemControl} />
</div>
)}
</FormArray.List>
<FormArray.Empty>
<div className="p-6 bg-gray-50 border-dashed border rounded-lg text-center text-gray-500">
No co-borrowers. Click the button above to add.
</div>
</FormArray.Empty>
</FormArray.Root>
)}
</div>
);
}
useFormArray Hookβ
For full customization without compound components:
import { useFormArray } from '@reformer/ui/form-array';
function CustomList({ control }) {
const { items, add, isEmpty, length } = useFormArray(control);
return (
<div>
<span>Total: {length}</span>
{items.map(({ control, id, remove }) => (
<div key={id}>
<ItemForm control={control} />
<button onClick={remove}>X</button>
</div>
))}
{isEmpty && <p>Empty</p>}
<button onClick={() => add()}>Add</button>
</div>
);
}
Best Practicesβ
1. Always Use memo()β
Wrap nested form components in memo to prevent unnecessary re-renders:
const AddressFormComponent = ({ control }: AddressFormProps) => { ... };
export const AddressForm = memo(AddressFormComponent);
2. Type Props via GroupNodeWithControlsβ
interface MyFormProps {
control: GroupNodeWithControls<MyType>;
}
3. Use Unique Keys for Array Itemsβ
Use the id property from controls as keys instead of array index:
{
control.map((itemControl, index) => <div key={itemControl.id || index}>...</div>);
}
4. Subscribe to Array Lengthβ
When rendering arrays, subscribe to the length property to trigger re-renders when items are added or removed:
const { length } = useFormControl(control);
5. Keep Components Focusedβ
Each nested form component should handle only its own fields. Don't pass the entire form to nested components.
Next Stepβ
Now that you understand how to create reusable forms and work with arrays, let's move on to adding behaviors to your form fields.