Validating Dynamically Added Form Rows
A “Add another” button is one of the most common patterns in forms — travellers on a booking, employers on an application, guests on an RSVP — and one of the most common sources of validation bugs. This recipe builds a repeating “guest” list in vanilla TypeScript: rows cloned from a <template>, stable ids baked into every name and id, validation through two delegated listeners that cover rows added at any time, a row limit and a minimum enforced at the list level, and focus handled explicitly on add, remove and failed submit. Everything reports through setCustomValidity(), checkValidity() and reportValidity() from the Constraint Validation API, which already validates whatever controls happen to be in the form when it runs.
When to Use This Pattern
Use it for any form section where the number of similar groups is chosen by the user. It is especially important when:
- Rows can be removed from the middle, which is where index-based names break.
- The server validates rows individually and returns errors that must land on the right row.
- Accessibility matters — which it always does — because added and removed content is invisible to screen reader users unless you manage focus and announcements.
For deeper structures — rows that contain nested objects, or objects inside objects — combine this with validating nested object fields. The broader design, including list-level rules and framework field arrays, is in the dynamic and repeating fields topic.
Minimal Working Repeating Rows
<form id="rsvp" novalidate>
<div id="guests" aria-describedby="guests-err"></div>
<p id="guests-err" class="field-error" tabindex="-1" hidden></p>
<button type="button" id="add-guest">Add another guest</button>
<p id="guests-status" role="status" class="visually-hidden"></p>
<button type="submit">Send RSVP</button>
</form>
<template id="guest-template">
<fieldset class="guest">
<legend></legend>
<label data-for="name">Full name</label>
<input data-field="name" autocomplete="off" required maxlength="100">
<p class="field-error" data-err="name" hidden></p>
<label data-for="email">Email</label>
<input data-field="email" type="email" autocomplete="off" required>
<p class="field-error" data-err="email" hidden></p>
<button type="button" class="remove-guest"></button>
</fieldset>
</template>
const MIN_GUESTS = 1;
const MAX_GUESTS = 8;
const form = document.querySelector<HTMLFormElement>("#rsvp")!;
const container = form.querySelector<HTMLElement>("#guests")!;
const template = document.querySelector<HTMLTemplateElement>("#guest-template")!;
const listError = form.querySelector<HTMLElement>("#guests-err")!;
const status = form.querySelector<HTMLElement>("#guests-status")!;
const rows = () => [...container.querySelectorAll<HTMLFieldSetElement>("fieldset.guest")];
function renumber(): void {
rows().forEach((row, i) => {
row.querySelector("legend")!.textContent = `Guest ${i + 1}`;
row.querySelector(".remove-guest")!.textContent = `Remove guest ${i + 1}`;
});
form.querySelector<HTMLButtonElement>("#add-guest")!.hidden = rows().length >= MAX_GUESTS;
}
function addGuest(): void {
if (rows().length >= MAX_GUESTS) return;
const id = crypto.randomUUID().slice(0, 8);
const row = (template.content.cloneNode(true) as DocumentFragment).querySelector("fieldset")!;
row.dataset.rowId = id;
for (const input of row.querySelectorAll<HTMLInputElement>("input[data-field]")) {
const field = input.dataset.field!;
input.name = `guests.${id}.${field}`; // stable, index-free name
input.id = `guest-${id}-${field}`;
row.querySelector<HTMLLabelElement>(`label[data-for="${field}"]`)!.htmlFor = input.id;
const err = row.querySelector<HTMLElement>(`[data-err="${field}"]`)!;
err.id = `${input.id}-err`;
input.setAttribute("aria-describedby", err.id);
}
container.append(row);
renumber();
row.querySelector<HTMLInputElement>("input")!.focus();
announce(`Guest ${rows().length} added.`);
}
function removeGuest(row: HTMLFieldSetElement): void {
const index = rows().indexOf(row);
row.remove();
renumber();
const neighbour = rows()[index] ?? rows()[index - 1];
(neighbour?.querySelector<HTMLInputElement>("input") ?? form.querySelector<HTMLButtonElement>("#add-guest")!).focus();
announce(`Guest ${index + 1} removed.`);
checkList(false);
}
function announce(text: string): void {
status.textContent = "";
requestAnimationFrame(() => (status.textContent = text));
}
// Delegated validation: works for rows that did not exist when this code ran.
function paint(input: HTMLInputElement): void {
const err = document.getElementById(`${input.id}-err`);
const bad = !input.validity.valid;
input.toggleAttribute("aria-invalid", bad);
if (err) { err.textContent = bad ? input.validationMessage : ""; err.hidden = !bad; }
}
form.addEventListener("focusout", (e) => { if ((e.target as HTMLElement).matches("fieldset.guest input")) paint(e.target as HTMLInputElement); });
form.addEventListener("input", (e) => {
const el = e.target as HTMLInputElement;
if (el.matches("fieldset.guest input") && el.hasAttribute("aria-invalid")) paint(el);
});
container.addEventListener("click", (e) => {
const btn = (e.target as Element).closest(".remove-guest");
if (btn) removeGuest(btn.closest("fieldset.guest")!);
});
form.querySelector("#add-guest")!.addEventListener("click", addGuest);
function checkList(focus: boolean): boolean {
const n = rows().length;
const message = n < MIN_GUESTS ? `Add at least ${MIN_GUESTS} guest.` : n > MAX_GUESTS ? `You can bring up to ${MAX_GUESTS} guests.` : "";
listError.textContent = message;
listError.hidden = !message;
if (message && focus) listError.focus();
return !message;
}
form.addEventListener("submit", (e) => {
e.preventDefault();
rows().forEach((row) => row.querySelectorAll<HTMLInputElement>("input").forEach(paint));
const fieldsOk = form.reportValidity(); // focuses the first invalid field in any row
const listOk = checkList(fieldsOk); // only steal focus if no field error took it
if (fieldsOk && listOk) form.submit();
});
addGuest(); // start with one row
Every behaviour that usually breaks is handled by structure rather than special cases. New rows are validated because the listeners are on the form. Removed rows stop blocking submission because their inputs leave form.elements the moment they leave the DOM. Server errors can target a row by id because namedItem("guests.7c1e02ab.email") finds the same input regardless of its position.
Repeating Row Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
MIN_GUESTS / MAX_GUESTS |
number |
1 / 8 |
List-level limits, checked on submit and removal |
| Row id | 8-char UUID prefix | per row | Embedded in names and ids |
| Name format | guests.<id>.<field> |
— | Matches schema paths and server error keys |
| Legend text | Guest n |
renumbered | Position for the user, not for the data |
| Remove label | Remove guest n |
renumbered | Unique accessible names |
| Focus on add | first input of new row | on | Puts the user where they need to type |
| Focus on remove | neighbour, else add button | on | Focus is never lost |
Verification Steps
import { test, expect } from "@playwright/test";
test("server errors land on the right guest after a removal", async ({ page }) => {
await page.goto("/rsvp");
const add = page.getByRole("button", { name: "Add another guest" });
await add.click();
await add.click();
const secondEmailName = await page.locator("fieldset.guest").nth(1).locator("input[type=email]").getAttribute("name");
await page.getByRole("button", { name: "Remove guest 1" }).click(); // indices shift, names do not
await page.evaluate((name) => {
const el = (document.querySelector("#rsvp") as HTMLFormElement).elements.namedItem(name!) as HTMLInputElement;
el.setCustomValidity("This guest is already on the list.");
}, secondEmailName);
await page.getByRole("button", { name: "Send RSVP" }).click();
await expect(page.locator(`[name="${secondEmailName}"]`)).toBeFocused();
});
Edge Cases and Failure Modes
Legends and labels drifting from data. Renumbering changes visible text (“Guest 2”), which is intentional, but error messages should not mention numbers that may change; say “Enter an email address” rather than “Guest 2’s email is invalid”. The error sits next to its field, so the position is clear.
Autocomplete in repeated rows. Browser autofill tends to fill every row’s email with the user’s own address. Use autocomplete="off" (or specific tokens such as section-guest2 email) on repeated contact fields.
Server rendering with existing rows. When editing a saved list, render rows server-side with their existing ids so names match the stored data; only generate new ids for rows added in the browser.
Removing the last row while it has focus and an error. The error message element is removed with the row, but a stale error summary at the top of the form may still link to it. Rebuild the summary after every add or remove, or clear it on edit.
Styling Rows That Contain Errors
When a list is long, users need to find the rows with problems quickly. The :has() selector lets CSS highlight a whole row whenever any input inside it is invalid and has been reported, with no extra script: fieldset.guest:has([aria-invalid="true"]) can receive a left border and a subtle background. Pair it with text — the field messages are already there — so the highlight is never the only signal, which WCAG 1.4.1 requires. For very long lists, add a compact count at the top of the list (“2 guests need attention”) that links to the first affected row; it complements the per-field messages rather than replacing them.
Sending Only the Rows That Changed
For long lists edited over time — a team roster, a product’s variants — resending every row on each save is wasteful and risks overwriting concurrent changes. Track each row’s state (new, changed, removed) by id, send only the changes, and let the server validate each change against the current stored list, including list-level rules like the maximum count. The dirty-tracking technique from tracking dirty, touched and pristine state extends naturally to rows: a row is dirty if any field differs from its snapshot, new if it has no snapshot, and removed if its snapshot exists but the row does not. Server errors for a row that another user has since deleted come back without a matching input and go to the form-level summary, as described in mapping server field errors to form inputs.
Frequently Asked Questions
How do I validate rows that are added to a form with JavaScript?
Attach focusout and input listeners to the form itself rather than to each input, so every row — including ones added later — is covered, and call form.reportValidity() on submit, which always sees the current controls.
Why should repeating rows have stable ids in their names?
Because index-based names change when a row is removed or reordered, so errors and server responses end up on the wrong row. An id assigned at creation never changes.
Where should focus go when a user removes a row?
To the first field of the row that took its place, or the previous row if it was the last, or the add button if the list is empty. Announce the removal in a status region.
Do removed rows still block form submission?
No. Once removed from the DOM, their inputs leave form.elements and are no longer validated. Rows hidden with CSS, however, still count, so remove or disable them instead of hiding.
Related Guides
- Dynamic and Repeating Fields — list rules, conditional sub-fields and performance.
- Validating Nested Object Fields — nested names inside rows.
- Validating React Hook Form Field Arrays — the same pattern with useFieldArray.
- Managing Focus After Validation Failure — focus rules applied on submit.