Row-count rules look trivial — items.length >= 1 — but their UX is where repeatable groups most often go wrong: “Add at least one item” shouts at a user who has not started, a delete button that is disabled on the last row gives no reason, and a pasted list of 40 addresses is silently cut to 10.

Count rules are array-level rules in the model from dynamic field arrays and repeatable groups: they belong to the collection, not to any row. This page covers when to evaluate them, where their messages go, how controls should behave at the limits, and how bulk entry interacts with them.


Context and prerequisites

Two kinds of limit behave differently:

  • Minimum (at least one contact, at least two references). Violated at the start by definition, so it must not be shown until the user has had a chance: on submit, or after they remove rows below the minimum.
  • Maximum (no more than ten line items, at most three attachments). Usually prevented at the source — the Add action stops at the limit — but still validated, because rows can arrive by paste, import, draft restore or a server-side change to the limit.

Separately, decide whether the minimum should be met by pre-rendering empty rows. Showing one empty row for “at least one” is friendly; showing two empty reference rows invites half-filled submissions. Pre-rendered empty rows must be ignored by the count if they are blank, or users will be told they have met the minimum with nothing entered.

When each count rule is evaluated and shown The minimum rule is evaluated continuously but shown only after a submit attempt or after the user removes rows below the minimum; at the minimum, remove buttons stay enabled and removing below it shows the message. The maximum rule is evaluated continuously and shown immediately if the count exceeds it; at the maximum the Add button is hidden or replaced by a text explaining the limit. Rule Shown when At the limit minimum after submit, or after removing below it remove stays enabled; message explains the rule maximum immediately when exceeded (paste, import) Add replaced by "You can add up to 10" blank rows not counted toward the minimum ignored at submit, or flagged if partly filled

The core pattern: count rules with display gating

export interface CountRules { min?: number; max?: number; noun: [string, string] } // ["contact", "contacts"]

export interface CountState {
  submitted: boolean;
  shrankBelowMin: boolean;   // set when a removal took the count below min
}

const isBlank = (row: Record<string, unknown>) =>
  Object.entries(row).every(([k, v]) => k.startsWith("_") || v === "" || v == null);

export function countMessage(rows: Record<string, unknown>[], r: CountRules, s: CountState): string | null {
  const filled = rows.filter((row) => !isBlank(row)).length;
  const [one, many] = r.noun;
  if (r.max !== undefined && rows.length > r.max) {
    // Always shown: the user (or a paste) put the form over the limit.
    const extra = rows.length - r.max;
    return `You can add up to ${r.max} ${many}. Remove ${extra} to continue.`;
  }
  if (r.min !== undefined && filled < r.min && (s.submitted || s.shrankBelowMin)) {
    return r.min === 1 ? `Add at least one ${one}.` : `Add at least ${r.min} ${many}.`;
  }
  return null;
}

export function canAdd(rows: unknown[], r: CountRules) {
  return r.max === undefined || rows.length < r.max;
}
<fieldset aria-describedby="contacts-count-error">
  <legend>Emergency contacts</legend>
  <p id="contacts-count-error" class="group-error" hidden></p>
  <!-- rows … -->
  <button type="button" data-action="add-contact">Add another contact</button>
  <p class="limit-note" hidden>You can add up to 3 contacts.</p>
</fieldset>

The message element lives at the top of the group, below the legend, and is referenced by the fieldset so a screen reader reads it on entering the group. At the maximum, swap the Add button for the limit note rather than disabling the button.


Step-by-step walkthrough

  1. Model counts as array-level rules. They never attach to a row; they attach to the group, which is where modelling form-level vs field-level errors puts group-scope errors.
  2. Exclude blank rows from the minimum. A pre-rendered empty row is not an answer. At submit, drop wholly blank rows from the payload, and flag partly filled ones with their own row errors.
  3. Gate the minimum message. Show it after a submit attempt, or immediately after a removal takes the group below the minimum — that removal is a deliberate act, and the user should learn its consequence at once.
  4. Show the maximum message immediately when exceeded. If the count is over the limit, something unusual happened (paste, import, a restored draft from before the limit changed), and the user must act.
  5. Replace the Add button at the limit. A visible “You can add up to 3 contacts” is better than a disabled button nobody can explain; see disabling submit buttons without hiding the reason.
  6. Link the summary to the right fix. For a minimum error, the summary link should go to the Add button (the action that fixes it); for a maximum error, to the group’s first row.
Should the count message show right now? If the number of rows exceeds the maximum, show the maximum message immediately, because rows arrived by paste, import or a restored draft. If a submit was attempted and the number of filled rows is below the minimum, show the minimum message. If the user just removed a row and the filled count is now below the minimum, show the minimum message. Otherwise show nothing, even if the minimum is not yet met. rows > max? Show max message now yes no Submitted and filled < min? Show min message yes no A removal took filled below min? Show min message yes no Show nothing An unstarted group is not an error.

Failure modes and edge cases

1. Disabling Remove on the last row

Preventing removal below the minimum stops users from clearing a mistaken row and starting again. Let them remove it; the minimum message then appears, and the Add button is right there. If the minimum is one and you pre-render a row, removing the last row can simply reset it to blank.

2. Silent truncation on paste

A “paste a list” feature that keeps the first ten of forty items loses thirty without telling anyone. Keep all forty, show the maximum message, and let the user choose which to remove — or offer to split them into another submission.

3. Limits that change

If the server lowers the limit from 10 to 5, drafts saved under the old limit restore with too many rows. The always-on maximum message covers it; migrations in versioning and migrating saved draft schemas should not truncate.

4. Schema-level rules

Zod’s .min(1) and .max(10) on the array produce an issue with the array’s path (["contacts"]) and codes too_small / too_big. Map those to the group’s count message rather than dropping them because they have no row index.

const Contacts = z.array(Contact).min(1, "Add at least one contact.").max(3, "You can add up to 3 contacts.");

5. Counting with conditional rows

If some rows are irrelevant (a “second guardian” row that only applies to minors), count only relevant rows toward the limits, using the relevance rule from what happens to errors when a field is hidden.

The group's controls at each count With no filled rows before submit, one blank row is shown, the Add button is available and no message appears. Within the limits, every row has a Remove button and the Add button is available. At the maximum, the Add button is replaced with a note saying you can add up to three contacts, and Remove buttons stay available. 0 filled, not submitted One blank row. Add available. No message. Within 1–3 Remove on every row. Add available. At 3 (max) Add replaced by "You can add up to 3 contacts." Remove still available.

Verification checklist


Frequently Asked Questions

Should I pre-render the minimum number of empty rows?

Pre-render one empty row when the minimum is one; it signals what to do. For higher minimums, render one row and let users add the rest, with a hint such as “You need at least two references”. Several blank rows at once look like several required fields and invite partial entries.

Where should the count be displayed?

A quiet “2 of 3 contacts” next to the Add button helps when a maximum exists, especially for screen-reader users who cannot glance at the list. Update it politely with the row changes rather than announcing it on every add.

Is an array-level error enough for accessibility?

Only if it is programmatically associated with the group (via the fieldset’s aria-describedby) and listed in the error summary. A message floating above the list without that association is invisible to someone navigating by form controls.


Related

← Dynamic Field Arrays and Repeatable Groups