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.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.