AppRepeater
A repeating group of form rows the user can add to and remove from.
<app-repeater><app-repeater-row>…</app-repeater-row></app-repeater><app-repeater> handles the list part of a “one or more of these” form — site contacts, invoice line items, zones. It owns the frame: numbering, the remove control, the add button, and the empty message.
What a row contains is your markup, written inside <app-repeater-row>. Rows are server-rendered from the model; the component does not own the list. The add and remove controls are submit buttons carrying a namespaced name, so wiring them is one line of C# each — see Handling the post.
Playground
Every frame attribute, live. Add and remove actually work here — clicking them round-trips through this form and adds or closes a row — so you can watch the frame respond; initial rows sets the starting count for the min/max/numbered/empty-text states.
PgContacts.add / PgContacts.remove; this playground answers them by adjusting its row count so you can see them work. A real page does the same round trip through an OnPost handler that mutates the model — wire it per Handling the post. (Unlike the React AppRepeater, which removes rows client-side, the Razor frame is always server-driven.)Basic
One <app-repeater-row> per model entry. Rows are numbered so a validation message can say which one is wrong.
Show code
<app-repeater name="Contacts" add-label="Add contact" remove-label="Remove contact" min="1">
@for (int i = 0; i < Model.Contacts.Count; i++)
{
<app-repeater-row>
<div class="row g-2">
<div class="col-sm-6"><app-input label="Name" for="Contacts[i].Name" /></div>
<div class="col-sm-6"><app-input label="Email" for="Contacts[i].Email" /></div>
</div>
</app-repeater-row>
}
</app-repeater>
Contacts[0].Name / Contacts[1].Name is what lets ASP.NET model binding rebuild the collection. Repeating an unindexed Name posts one value and silently loses the rest.Handling the post
Both controls are submit buttons with a namespaced name, derived from the repeater's name attribute. Set it to the collection name and the handler reads like the model.
@* <app-repeater name="Contacts" …> posts "Contacts.add" / "Contacts.remove" *@
public IActionResult OnPost()
{
if (Request.Form.ContainsKey("Contacts.add"))
{
Contacts.Add(new ContactInput());
return Page(); // re-render; do not validate yet
}
if (int.TryParse(Request.Form["Contacts.remove"], out int i))
{
Contacts.RemoveAt(i); // the posted value is ZERO-based
return Page();
}
if (!ModelState.IsValid) return Page();
// … a real save
}
name when a page has two repeatersWithout it both post the same repeater.add / repeater.remove keys and the server cannot tell which list the click belonged to. The remove value is the zero-based collection index; the number the user sees is 1-based, and those are deliberately different so the handler can index directly.Minimum and maximum
At min the remove control disappears rather than failing on click; at max the add button is disabled. Both communicate the rule before the user hits it.
min="1" max="3" — the add button is disabled at the ceiling.Show code
<app-repeater min="1" max="3" add-label="Add contact">…</app-repeater>
The empty state
With no rows and no message, a repeater is a lone Add button floating in space — it reads as broken rather than empty. empty-text fills the gap.
Show code
<app-repeater add-label="Add contact"
empty-text="No contacts yet. Add at least one for site access." />
Per-row labels
Set label on an <app-repeater-row> to show a label in that row's top-left header band, opposite the remove control — useful when rows are roles or categories rather than an anonymous list. Omit it to leave a row unlabelled. React's counterpart is the rowLabel(row, index) prop.
Show code
<app-repeater name="Contacts" add-label="Add contact" min="1">
<app-repeater-row label="Primary contact">…</app-repeater-row>
<app-repeater-row label="Contact">…</app-repeater-row>
</app-repeater>
With react-hook-form
React-only concept — react-hook-form's useFieldArray is the intended pairing for the React AppRepeater. The Razor tag helper has no client-side form library; the equivalent is ASP.NET Core model binding with indexed row names, which every example on this page already uses.
useFieldArray to get stable row ids and array mutation for free. Razor rows are server-rendered from the model and posted with indexed names (Contacts[0].Name, Contacts[1].Name, …) — see Basic and Handling the post above for the full pattern. There is no client-side array state to manage; the round trip through OnPost is the array mutation.Custom row rendering
A row's inner markup is whatever you write inside <app-repeater-row> — not limited to a pair of text inputs. Here each row mixes a searchable select with a number field.
Show code
<app-repeater name="LineItems" add-label="Add line item" remove-label="Remove line item" min="1">
<app-repeater-row>
<div class="row g-2">
<div class="col-sm-8">
<app-select label="Item" name="LineItems[0].Kind">
<app-option value="Consulting">Consulting</app-option>
…
</app-select>
</div>
<div class="col-sm-4"><app-number-input label="Qty" name="LineItems[0].Qty" min="1" value="1" /></div>
</div>
</app-repeater-row>
</app-repeater>
When to use
Use it when
- A form needs one or more of the same group of fields.
- The count is decided by the user, not the schema.
- Each entry has more than one field.
Reach for something else when
- The user is picking from a known list. → app-multi-select
- It is a single repeated value, like tags. → app-multi-select / app-chip
- The rows are existing records to manage. → app-data-table
- The count is fixed by the schema. → plain fields
Best practices
Every option
The whole surface of the frame — bounds, numbering, the remove affordance, and the empty state.
min / max
min="0" (default) — can remove down to zero
min="1" — last row's remove control hidden
max="2", at max — add button disabled
max="0" (default) — add button never disables
numbered
numbered="true" (default)
numbered="false"
add button variant
add-variant="secondary" (default)
add-variant="primary"
add-variant="outline"
add-variant="ghost"
remove affordance
rows.Count > min — remove control per row
min="2" == row count — nearest equivalent to React's onRemove-omitted (rows read-only)
empty state
empty-text set, no rows
no empty-text, no rows — a lone Add button
custom row shapes
two-field row (name + email)
select + number row
AppRepeater hides every remove control when onRemove is omitted, regardless of row count. The Razor tag helper has no such switch — it always shows the control once rows.Count > min. Setting min to the current row count is the nearest equivalent (read-only until a row is added), but it is not the same guarantee: adding a row past that count re-enables removal, where React's rows stay permanently read-only until the caller passes an onRemove.Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| name | string | repeater | Namespaces the add/remove buttons. Set it to the collection name so two repeaters on a page cannot collide. |
| add-label | string | Add another | Be specific — “Add contact”. |
| add-variant | string | secondary | The add button's variant: primary, outline, ghost and the rest of <app-button>'s list. |
| remove-label | string | Remove row | Suffixed with the row number for the accessible name. |
| empty-text | string | — | Shown in place of the rows when there are none. |
| min | int | 0 | At or below this count the remove control is hidden. |
| max | int | 0 | Adding is disabled at this count. 0 means no ceiling. |
| numbered | bool | true | Show the row index. Keep it on where validation refers to rows. |
| add-form-action | string | — | Where the add button posts. Omit for a page script to handle it. |