Repeatable Fields
Add dynamic, repeatable field groups to your forms with min/max constraints, validation, and reordering.
Repeatable fields let users add, remove, and reorder groups of fields at runtime — "Add another item", "Add another contact", any list-like structure.
Defining Repeatable Fields
Use .addRepeatable() on the form builder. It takes an ID and a callback that receives a RepeatableBuilder.
import { form } from '@rilaykit/forms';
import { required } from '@rilaykit/core';
import { rilay } from '@/lib/rilay';
const orderForm = form
.create(rilay, 'order')
.add({ id: 'customerName', type: 'text', props: { label: 'Customer' } })
.addRepeatable('items', (r) => r
.add(
{ id: 'name', type: 'text', props: { label: 'Item' }, validation: { validate: required() } },
{ id: 'qty', type: 'number', props: { label: 'Qty' } }
)
.min(1)
.max(10)
.defaultValue({ name: '', qty: 1 })
);RepeatableBuilder API
All methods are chainable.
| Method | Description |
|---|---|
.add(...fields) | Add fields to the template. Same API as form.add() — fields passed in one call share a row. |
.addSeparateRows(fields) | Add fields each on their own row. |
.min(n) | Minimum number of items (default 0). |
.max(n) | Maximum number of items (unlimited if not set). |
.defaultValue(obj) | Default values used when appending new items. |
.validation(config) | Group-level validation for the entire array. |
Repeatables are strictly one level deep. Nesting a repeatable inside another throws at build time.
Rendering Repeatable Fields
Automatic with <FormBody>
<FormBody> renders each repeatable as a <FormList> with default markup (item rows plus an Add button).
import { Form, FormBody, FormSubmit } from '@rilaykit/forms/react';
function OrderPage() {
return (
<Form of={orderForm} onSubmit={(data) => console.log(data)}>
<FormBody />
<FormSubmit>Place Order</FormSubmit>
</Form>
);
}Custom Markup with <FormList>
<FormList> accepts a render prop for custom layout:
import { FormList, FormField } from '@rilaykit/forms/react';
<FormList id="items">
{({ items, add, remove, canAdd, canRemove }) => (
<div>
{items.map((item) => (
<div key={item.key} className="flex gap-2 items-end">
{item.allFields.map((field) => (
<FormField key={field.id} id={field.id} config={field} />
))}
{canRemove && (
<button type="button" onClick={() => remove(item.key)}>Remove</button>
)}
</div>
))}
{canAdd && (
<button type="button" onClick={add}>Add Item</button>
)}
</div>
)}
</FormList>Full Control with useRepeatableField
The hook behind <FormList>:
interface UseRepeatableFieldReturn {
config: RepeatableFieldConfig | undefined; // undefined when the id is unknown
items: RepeatableFieldItem[]; // Scoped items with composite field IDs
append: (defaultValue?: Record<string, unknown>) => void;
remove: (key: string) => void;
move: (fromIndex: number, toIndex: number) => void;
canAdd: boolean; // false when count >= max
canRemove: boolean; // false when count <= min
count: number;
}
interface RepeatableFieldItem {
key: string; // Unique stable key for React
index: number; // Current position
rows: FormFieldRow[]; // Scoped row configs
allFields: FormFieldConfig[]; // Scoped field configs with composite IDs
}move(fromIndex, toIndex) reorders items — wire it to drag-and-drop or up/down buttons:
import { useRepeatableField } from '@rilaykit/forms/react';
function ReorderableList() {
const { items, move } = useRepeatableField('items');
return items.map((item, index) => (
<div key={item.key}>
{/* fields... */}
<button type="button" disabled={index === 0} onClick={() => move(index, index - 1)}>
Up
</button>
<button
type="button"
disabled={index === items.length - 1}
onClick={() => move(index, index + 1)}
>
Down
</button>
</div>
));
}Default Values
Pass arrays at the top level of defaults — RilayKit handles the flat-to-nested conversion both ways, so onSubmit receives the same nested shape back:
<Form
of={orderForm}
defaults={{
customerName: 'Acme Corp',
items: [
{ name: 'Widget', qty: 5 },
{ name: 'Gadget', qty: 2 },
],
}}
onSubmit={handleSubmit} // receives { customerName, items: [...] }
>
{/* ... */}
</Form>Validation
Fields inside a repeatable validate like static fields, independently per item. Timing follows the form-level .setValidation({ mode, reValidateMode }) configuration — see Validation.
If the item count is below min, validation produces an error with code REPEATABLE_MIN_COUNT. At runtime, canAdd / canRemove reflect the constraints so you can disable buttons before validation runs.
Cross-field (form-level) rules that target repeatable fields by dot path (items.0.price) never match the composite ids (items[k0].price) — their issues land in the __form__ bucket, readable via useFormErrors().
Conditions
Conditions inside a repeatable are scoped to the current item: when('type') reads that item's type, not another item's.
import { when } from '@rilaykit/core';
.addRepeatable('contacts', (r) => r
.add(
{ id: 'type', type: 'select', props: { label: 'Type', options: ['email', 'phone'] } },
{
id: 'email',
type: 'email',
props: { label: 'Email' },
conditions: { visible: when('type').equals('email') },
},
{
id: 'phone',
type: 'tel',
props: { label: 'Phone' },
conditions: { visible: when('type').equals('phone') },
},
)
)