Tulip Logo IconTulip

Repeater

Compose editable lists with stable item identity and controlled add, remove, and reorder actions.

Import from @tulip-systems/ui/repeater.

A controlled collection of repeated fields. The root receives data, a stable getItemKey, and optional onAdd, onRemove, and onMove callbacks. Callbacks request changes; the caller updates the collection. Inputs remain caller-owned. No form library, mutation client, or drag-and-drop library is required.

Simple list

Email addresses
import { useState } from "react";
import { Input } from "@tulip-systems/ui/input";
import { FieldLegend, FieldSet } from "@tulip-systems/ui/field";
import {
  Repeater,
  RepeaterAdd,
  RepeaterItem,
  RepeaterItems,
  RepeaterRemove,
} from "@tulip-systems/ui/repeater";

type Email = { id: string; address: string };

export function Emails() {
  const [emails, setEmails] = useState<Email[]>([]);

  return (
    <FieldSet>
      <FieldLegend>Email addresses</FieldLegend>
      <Repeater
        data={emails}
        getItemKey={(email) => email.id}
        onAdd={() => {
          const item = { id: crypto.randomUUID(), address: "" };
          setEmails((current) => [...current, item]);
        }}
        onRemove={({ key }) =>
          setEmails((current) => current.filter((email) => email.id !== key))
        }
      >
        <RepeaterItems>
          {emails.map((email, index) => (
            <RepeaterItem key={email.id} value={email.id} className="flex-row">
              <Input
                aria-label={`Email address ${index + 1}`}
                type="email"
                value={email.address}
                onChange={(event) => {
                  const address = event.target.value;
                  setEmails((current) => current.map((item) =>
                    item.id === email.id ? { ...item, address } : item
                  ));
                }}
              />
              <RepeaterRemove aria-label={`Remove email address ${index + 1}`}>
                Remove
              </RepeaterRemove>
            </RepeaterItem>
          ))}
        </RepeaterItems>
        <RepeaterAdd className="self-start">Add email address</RepeaterAdd>
      </Repeater>
    </FieldSet>
  );
}

With input groups

Place the trash action inside an InputGroupAddon using RepeaterRemove asChild and InputGroupButton. The full-width plus button adds another email address.

Email addresses

Complex items and ordering

Project contacts

Add people and set their order.

Alice
Bob

RepeaterHeader, RepeaterItemHeader, and RepeaterItemContent are optional layout primitives. Compose normal Field, Input, and Textarea components inside them. Use Collapsible when details should fold away; the repeater does not impose a collapsed state, card border, or item schema.

import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";

<Repeater
  data={contacts}
  getItemKey={(contact) => contact.id}
  onAdd={addContact}
  onRemove={({ key }) => removeContact(key)}
  onMove={({ fromIndex, toIndex }) => {
    setContacts((current) => {
      const next = [...current];
      const [item] = next.splice(fromIndex, 1);
      if (item) next.splice(toIndex, 0, item);
      return next;
    });
  }}
>
  <RepeaterHeader>
    <FieldTitle>Contacts</FieldTitle>
  </RepeaterHeader>
  <RepeaterItems>
    {contacts.map((contact) => (
      <RepeaterItem key={contact.id} value={contact.id} className="rounded-lg border p-4">
        <RepeaterItemHeader>
          <FieldTitle>{contact.name || "New contact"}</FieldTitle>
          <div className="flex gap-2">
            <RepeaterMove direction="up" size="icon-sm" aria-label="Move up">
              <ArrowUpIcon aria-hidden="true" />
            </RepeaterMove>
            <RepeaterMove direction="down" size="icon-sm" aria-label="Move down">
              <ArrowDownIcon aria-hidden="true" />
            </RepeaterMove>
            <RepeaterRemove size="icon-sm" aria-label={`Remove ${contact.name || "contact"}`}>
              <Trash2Icon aria-hidden="true" />
            </RepeaterRemove>
          </div>
        </RepeaterItemHeader>
        <RepeaterItemContent>
          <ContactFields contact={contact} onChange={updateContact} />
        </RepeaterItemContent>
      </RepeaterItem>
    ))}
  </RepeaterItems>
  <RepeaterAdd className="w-full" aria-label="Add contact">
    <PlusIcon aria-hidden="true" />
  </RepeaterAdd>
</Repeater>

Here ContactFields is an application component containing ordinary labeled inputs and textareas. The callbacks and state come from the application.

API and extension points

  • onRemove receives { item, key, index }.
  • onMove receives { item, key, fromIndex, toIndex }. toIndex is the final index in the resulting array. Invalid, out-of-range, and unchanged moves do nothing.
  • useRepeater() exposes keys, disabled, readOnly, canAdd, canRemove, canMove, add(), remove(key), and move(key, toIndex).
  • useRepeaterItem() exposes the nearest item's key, index, canRemove, canMoveUp, canMoveDown, remove(), and move(toIndex).
  • Missing callbacks disable the corresponding controls. disabled and readOnly guard collection actions, including hooks. They do not disable arbitrary child inputs: pass the state to those inputs or wrap the form in a disabled fieldset.
  • All layout components support asChild, className, DOM props, and React 19 refs. Action components accept Button props and default to type="button". With asChild, use a real button or a component forwarding button semantics. onClick can prevent the default repeater action via event.preventDefault().
  • Keys are unique strings or numbers. Use persistent IDs for objects. Primitive values may be keys only when unique and stable; wrap duplicate/editable strings in { id, value } objects. Use the same identity for React's key and item value.
  • RepeaterItems/RepeaterItem provide list/listitem roles. Label inputs and icon-only actions explicitly. Place the collection in a labeled FieldSet.
  • Creation UI, focus after asynchronous creation/deletion, empty states, validation, saving indicators, and error recovery are composed by the caller. There is no automatic focus movement or implicit deletion confirmation.

A future InlineRepeater can wrap Repeater and connect these callbacks to mutations. It can compose the same layouts, disable actions while pending, and supply refreshed or optimistic data. No changes to the base item content are needed. Async callbacks must handle their own errors; the base does not await or manage promises. Custom sorting controls can call move, without requiring a specific drag-and-drop implementation in the UI package.

On this page