FormRepeater
FormControlled repeatable form rows with stable identities and add/remove limits.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { FormRepeater, type RepeaterItem } from "@/components/ui/form-repeater";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { useState } from "react";
export default function FormRepeaterExample() {
const [contacts, setContacts] = useState<
RepeaterItem<{ name: string; email: string }>[]
>([
{ id: "owner", value: { name: "Alex Morgan", email: "alex@example.com" } },
]);
return (
<div className="w-full max-w-xl space-y-6">
<div>
<h3 className="font-semibold">Project collaborators</h3>
<p className="mt-1 text-sm text-muted-foreground">
Add up to three people to your launch checklist.
</p>
</div>
<FormRepeater
label="Collaborators"
items={contacts}
onItemsChange={setContacts}
createItem={() => ({ name: "", email: "" })}
maxItems={3}
renderItem={(item, update, index) => (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${item.id}-name`}>Name {index + 1}</Label>
<Input
id={`${item.id}-name`}
name={`contacts[${index}].name`}
value={item.value.name}
onChange={(event) =>
update({ ...item.value, name: event.target.value })
}
placeholder="Full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${item.id}-email`}>Email {index + 1}</Label>
<Input
id={`${item.id}-email`}
type="email"
name={`contacts[${index}].email`}
value={item.value.email}
onChange={(event) =>
update({ ...item.value, email: event.target.value })
}
placeholder="name@example.com"
/>
</div>
</div>
)}
/>
<FormRepeater
label="Required owner"
minItems={1}
maxItems={1}
disabled
items={[{ id: "locked", value: "Workspace administrator" }]}
onItemsChange={() => {}}
createItem={() => ""}
renderItem={(item) => <p className="py-2 text-sm">{item.value}</p>}
/>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Collection legend and remove button naming. |
| items | RepeaterItem<T>[]Default: required | Controlled rows with a unique id and typed value. |
| onItemsChange | (items: RepeaterItem<T>[]) => voidDefault: required | Apply additions, removals, and row value edits. |
| createItem | () => TDefault: required | Construct an independent value for each added row. |
| renderItem | (item, update, index) => ReactNodeDefault: required | Render labeled controls and call update with the complete row value. |
| minItems / maxItems | numberDefault: 0 / 10 | Bounds for remove/add actions; maxItems is at least minItems. |
| disabled | booleanDefault: false | Disable collection controls and native descendant inputs. |
| emptyText | stringDefault: No items yet. Add your first item. | Message shown when there are no rows. |