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
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.
Complex items and ordering
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
onRemovereceives{ item, key, index }.onMovereceives{ item, key, fromIndex, toIndex }.toIndexis the final index in the resulting array. Invalid, out-of-range, and unchanged moves do nothing.useRepeater()exposeskeys,disabled,readOnly,canAdd,canRemove,canMove,add(),remove(key), andmove(key, toIndex).useRepeaterItem()exposes the nearest item'skey,index,canRemove,canMoveUp,canMoveDown,remove(), andmove(toIndex).- Missing callbacks disable the corresponding controls.
disabledandreadOnlyguard 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 totype="button". WithasChild, use a real button or a component forwarding button semantics.onClickcan prevent the default repeater action viaevent.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'skeyand itemvalue. RepeaterItems/RepeaterItemprovide list/listitem roles. Label inputs and icon-only actions explicitly. Place the collection in a labeledFieldSet.- 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.