rilaykit
Forms

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.

MethodDescription
.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') },
    },
  )
)

On this page