Skip to content

FormRepeater

Form

Controlled repeatable form rows with stable identities and add/remove limits.

Editable collaboratorsMaximum three rowsEmpty collectionDisabled required owner
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Own an array of RepeaterItem<T> records with unique, stable ids. Supply createItem for a fresh value and renderItem for your labeled inputs; the provided update callback replaces only that row value. Include names in rendered inputs when submitting a native form. Limits prevent user actions at the boundaries; they do not synthesize missing rows or truncate externally supplied data.

Import
import { FormRepeater, type RepeaterItem } from "@/components/ui/form-repeater";

Shipped as editable source in src/components/ui/form-repeater.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/form-repeater.tsx
"use client";
import { useId, useLayoutEffect, useRef, type ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
export interface RepeaterItem<T> {
  id: string;
  value: T;
}
export interface FormRepeaterProps<T> {
  label: string;
  items: RepeaterItem<T>[];
  onItemsChange: (items: RepeaterItem<T>[]) => void;
  createItem: () => T;
  renderItem: (
    item: RepeaterItem<T>,
    update: (value: T) => void,
    index: number,
  ) => ReactNode;
  minItems?: number;
  maxItems?: number;
  disabled?: boolean;
  emptyText?: string;
}
export function FormRepeater<T>({
  label,
  items,
  onItemsChange,
  createItem,
  renderItem,
  minItems = 0,
  maxItems = 10,
  disabled,
  emptyText = "No items yet. Add your first item.",
}: FormRepeaterProps<T>) {
  const id = useId();
  const counter = useRef(0);
  const addRef = useRef<HTMLButtonElement>(null);
  const restoreFocus = useRef(false);
  useLayoutEffect(() => {
    if (restoreFocus.current) {
      restoreFocus.current = false;
      addRef.current?.focus();
    }
  }, [items.length]);
  const minimum = Math.max(0, Math.floor(minItems) || 0);
  const maximum = Math.max(
    minimum,
    Number.isFinite(maxItems) ? Math.max(0, Math.floor(maxItems)) : 10,
  );
  return (
    <fieldset disabled={disabled} className="min-w-0 space-y-4">
      <legend className="mb-3 text-sm font-semibold">{label}</legend>
      <div className="space-y-3">
        {items.map((item, index) => (
          <fieldset
            key={item.id}
            className="min-w-0 rounded-lg border bg-card p-4"
          >
            <legend className="px-1 text-xs text-muted-foreground">
              Item {index + 1}
            </legend>
            <div className="flex items-start gap-3">
              <div className="min-w-0 flex-1">
                {renderItem(
                  item,
                  (value) =>
                    onItemsChange(
                      items.map((current) =>
                        current.id === item.id
                          ? { ...current, value }
                          : current,
                      ),
                    ),
                  index,
                )}
              </div>
              <Button
                type="button"
                variant="ghost"
                size="icon"
                aria-label={`Remove ${label} item ${index + 1}`}
                disabled={disabled || items.length <= minimum}
                onClick={() => {
                  restoreFocus.current = true;
                  onItemsChange(
                    items.filter((current) => current.id !== item.id),
                  );
                }}
              >
                <Trash2 className="size-4" />
              </Button>
            </div>
          </fieldset>
        ))}
      </div>
      {items.length === 0 && (
        <p className="rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
          {emptyText}
        </p>
      )}
      <div className="flex flex-wrap items-center gap-3">
        <Button
          ref={addRef}
          type="button"
          variant="outline"
          disabled={disabled || items.length >= maximum}
          onClick={() => {
            let key: string;
            do {
              key = `${id}-${++counter.current}`;
            } while (items.some((item) => item.id === key));
            onItemsChange([...items, { id: key, value: createItem() }]);
          }}
        >
          <Plus className="size-4" />
          Add item
        </Button>
        <span role="status" className="text-xs text-muted-foreground">
          {items.length} of {maximum} items
        </span>
      </div>
    </fieldset>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

FormRepeater props
PropType / DefaultDescription
labelstring

Default: required

Collection legend and remove button naming.
itemsRepeaterItem<T>[]

Default: required

Controlled rows with a unique id and typed value.
onItemsChange(items: RepeaterItem<T>[]) => void

Default: required

Apply additions, removals, and row value edits.
createItem() => T

Default: required

Construct an independent value for each added row.
renderItem(item, update, index) => ReactNode

Default: required

Render labeled controls and call update with the complete row value.
minItems / maxItemsnumber

Default: 0 / 10

Bounds for remove/add actions; maxItems is at least minItems.
disabledboolean

Default: false

Disable collection controls and native descendant inputs.
emptyTextstring

Default: No items yet. Add your first item.

Message shown when there are no rows.

Build with these too