Special Field

Repeater

A dynamic list field — users add and remove groups of sub-fields, and the field submits an array of objects.

stable
repeater array list dynamic field-array form

Interactive demo of a repeater field — add and remove team members

Add up to 4 team members

/**
 * Repeater Field Demo - Dynamic list of sub-fields with add/remove
 */

import { Form, FormBuilder } from '@saastro/forms';

import { FormProvider } from '@/components/FormProvider';
import { TooltipProvider } from '@/components/ui/tooltip';

const config = FormBuilder.create('repeater-demo')
  .addField('teamName', (f) =>
    f
      .type('text')
      .label('Team Name')
      .placeholder('Enter a team name')
      .required('Team name is required'),
  )
  .addField('members', (f) =>
    f
      .type('repeater')
      .label('Team Members')
      .helperText('Add up to 4 team members')
      .itemFields({
        name: {
          type: 'text',
          label: 'Name',
          placeholder: 'Member name',
        },
        email: {
          type: 'email',
          label: 'Email',
          placeholder: '[email protected]',
        },
        role: {
          type: 'select',
          label: 'Role',
          placeholder: 'Select a role...',
          options: [
            { label: 'Developer', value: 'developer' },
            { label: 'Designer', value: 'designer' },
            { label: 'Product Manager', value: 'pm' },
          ],
        },
      })
      .minItems(1)
      .maxItems(4)
      .addLabel('Add member')
      .removeLabel('Remove')
      .value([{ name: '', email: '', role: '' }])
      .optional(),
  )
  .addStep('main', ['teamName', 'members'])
  .build();

export default function RepeaterDemo() {
  const handleSubmit = (data: Record<string, unknown>) => {
    console.log('Form submitted:', data);
    alert('Form submitted! Check console for data.');
  };

  return (
    <TooltipProvider>
      <FormProvider>
        <Form config={config} onSubmit={handleSubmit} className="space-y-4" />
      </FormProvider>
    </TooltipProvider>
  );
}

Overview

The repeater renders a dynamic list of items. Each item is a bordered card containing the sub-fields you declare in itemFields; users append new items with the add button and delete existing ones with the remove button. Internally the field is powered by react-hook-form’s useFieldArray, and its value is an array of objects — one object per item.

Typical uses: contact lists, order line items, multiple addresses, team members, “add another” sections of any kind.

Usage

FormBuilder API

import { FormBuilder } from '@saastro/forms';

const config = FormBuilder.create('order')
  .addField('items', (f) =>
    f
      .type('repeater')
      .label('Order Items')
      .itemFields({
        product: { type: 'text', label: 'Product', placeholder: 'Product name' },
        quantity: { type: 'number', label: 'Quantity', placeholder: '1' },
      })
      .minItems(1)
      .maxItems(10)
      .addLabel('Add item')
      .removeLabel('Remove')
      .value([{ product: '', quantity: '' }]),
  )
  .addStep('main', ['items'])
  .build();

JSON Configuration

{
  "type": "repeater",
  "label": "Order Items",
  "itemFields": {
    "product": { "type": "text", "label": "Product", "placeholder": "Product name" },
    "quantity": { "type": "number", "label": "Quantity", "placeholder": "1" }
  },
  "minItems": 1,
  "maxItems": 10,
  "addLabel": "Add item",
  "removeLabel": "Remove"
}

Value Shape

A repeater named items submits an array of objects, one per item, keyed by the itemFields names:

// Submitted data:
{
  items: [
    { product: 'Keyboard', quantity: '2' },
    { product: 'Mouse', quantity: '1' },
  ]
}

number sub-fields hold string values at runtime, like the standalone number input. Convert in your submit handler if you need numbers.

Sub-field naming

Each rendered input is registered with react-hook-form under the path {name}.{index}.{subName} — the standard useFieldArray convention:

items.0.product
items.0.quantity
items.1.product
items.1.quantity

You’ll see these paths when inspecting form state or reading values through react-hook-form APIs.

Initial and default values

  • The repeater’s default value is []the list starts empty unless you set value to an array of item objects (as in the examples above).
  • When the user clicks the add button, the new item initializes each sub-field to that sub-field’s own value from itemFields, or '' when unset.

Item Limits

  • minItems (default 0) — the remove button is hidden whenever removing would drop the list below this count. It does not pre-create items and is not validated; to actually start with items, set the field’s value.
  • maxItems (default: unlimited) — the add button is hidden once the list reaches this count.

Props

PropTypeDefaultDescription
type'repeater'-Field type (required)
itemFieldsRecord<string, FieldConfig>-Sub-field definitions for each item (required)
minItemsnumber0Hide the remove button at or below this item count
maxItemsnumber- (unlimited)Hide the add button at this item count
addLabelstring'Add item'Label of the add button
removeLabelstring'Remove'Label of the remove button
labelstring-Label shown above the list
hideLabelbooleanfalseHide the label visually
helperTextstring-Help text shown below the list
valueArray<Record<string, unknown>>[]Initial items
hiddenboolean | function | ConditionGroup | ResponsivefalseHide the field
columnsPartial<Record<Breakpoint, number>>-Grid columns by breakpoint

Validation

The repeater bypasses schema validation entirely — the form engine always validates its value as z.any(). This means:

  • A schema (or required) set on the repeater field itself is ignored.
  • schema and required settings on the sub-fields inside itemFields are not enforced either — the form-level validation schema only covers top-level fields, and the whole array is accepted as-is.

If you need to enforce constraints (e.g. at least one item, valid emails inside items), check the array in your onSubmit handler or validate server-side.

How It Works

  1. The repeater mounts a useFieldArray on the field’s name — it relies on the react-hook-form context that <Form> creates, so it only works inside a rendered form (it can’t be used standalone).
  2. Each item renders its sub-fields recursively through the same field renderer used for top-level fields, registered under {name}.{index}.{subName}.
  3. The add button appends a default item built from the itemFields definitions; the remove button deletes the item at its index.
  4. Add/remove buttons hide themselves according to maxItems/minItems.

Required Components

The repeater itself needs Button (for the add/remove buttons) plus the field wrapper components (FormField, Field, FieldDescription, FieldError). Each sub-field additionally requires its own components — e.g. Input for text sub-fields, the Select cluster for select sub-fields. See the Components guide for how injection works.