Specialized inputs

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.

1
2
<app-repeater name="PgContacts" add-label="Add contact" remove-label="Remove contact">
    <app-repeater-row>
        <div class="row g-2">
            <div class="col-sm-6"><app-input label="Name" name="PgContacts[0].Name" /></div>
            <div class="col-sm-6"><app-input label="Email" name="PgContacts[0].Email" type="email" /></div>
        </div>
    </app-repeater-row>
    <app-repeater-row>
        <div class="row g-2">
            <div class="col-sm-6"><app-input label="Name" name="PgContacts[1].Name" /></div>
            <div class="col-sm-6"><app-input label="Email" name="PgContacts[1].Email" type="email" /></div>
        </div>
    </app-repeater-row>
</app-repeater>
Add/remove are a server round trip, not client JSThe buttons above post 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.

A contacts list
Index the field names so model binding rebuilds the collection on post.
1
2
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>
Index the field names, not just the labelsContacts[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.

The page model
Add and remove re-render the page rather than saving, so the user is not shown validation errors for rows they have not filled in yet.
@* <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
}
Always set 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.

At least one, at most three
Three rows with min="1" max="3" — the add button is disabled at the ceiling.
1
2
3
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.

No rows yet
Say what the list is for, so the Add button has context.
No contacts yet. Add at least one for site access.
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.

Label each row
The label sits above the fields, clear of them, and pairs with the row number.
Primary contact1
Contact2
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.

Razor's equivalent is model binding, not a form libraryThe React example wires 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.

Line items
A different row shape entirely, inside the same <app-repeater> frame.
1
Consulting Hosting Support License
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

Do
1
2
Specific add/remove labels (“Add contact”), a minimum so the form cannot reach an unsubmittable state, and numbered rows validation can point at.
Don't
A generic “Add another”, no remove control at all (one row at the minimum), and no numbering — so “Row 2 email is invalid” has nothing to point at.

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
1
min="1" — last row's remove control hidden
1
max="2", at max — add button disabled
1
2
max="0" (default) — add button never disables
1
2
3
4
5
6

numbered

numbered="true" (default)
1
2
numbered="false"

add button variant

add-variant="secondary" (default)
1
add-variant="primary"
1
add-variant="outline"
1
add-variant="ghost"
1

remove affordance

rows.Count > min — remove control per row
1
2
min="2" == row count — nearest equivalent to React's onRemove-omitted (rows read-only)
1
2

empty state

empty-text set, no rows
No contacts yet. Add at least one for site access.
no empty-text, no rows — a lone Add button

custom row shapes

two-field row (name + email)
1
select + number row
1
Consulting Hosting
Removability is boolean-driven on React, threshold-driven on RazorReact's 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

AttributeTypeDefaultDescription
namestringrepeaterNamespaces the add/remove buttons. Set it to the collection name so two repeaters on a page cannot collide.
add-labelstringAdd anotherBe specific — “Add contact”.
add-variantstringsecondaryThe add button's variant: primary, outline, ghost and the rest of <app-button>'s list.
remove-labelstringRemove rowSuffixed with the row number for the accessible name.
empty-textstring—Shown in place of the rows when there are none.
minint0At or below this count the remove control is hidden.
maxint0Adding is disabled at this count. 0 means no ceiling.
numberedbooltrueShow the row index. Keep it on where validation refers to rows.
add-form-actionstring—Where the add button posts. Omit for a page script to handle it.