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.

Guest list with a row error An RSVP form with two guest rows, the second row's email in an error state, per-row remove buttons with specific names, and an add button, annotated with the ARIA wiring. Who's coming? Guest 1 — full name Ada Lovelace Guest 1 — email ada@example.com Guest 2 — full name Charles Babbage Guest 2 — email charles@ 1 ✗ Enter an email address like name@example.com Add another guest 1 Name is guests.7c1e02ab.email: the id, not the index, survives removals 2 Each Remove button is named "Remove guest 2", not just "Remove" 3 The fieldset's legend numbers the guest so users know where they are
Each row has its own labelled inputs and a uniquely named remove button; errors are keyed by the row's id, not its position.

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.

Lifecycle of a dynamically added row A row is cloned from the template, given a stable id in every name and id, appended and focused, validated by delegated listeners, and eventually submitted or removed with focus moved to a neighbour. Clone template <template> content Assign id names, ids, label for, describedby Append + focus announce "Guest 3 added" Delegated checks focusout, input Remove or submit focus neighbour / reportValidity
The stable id is assigned once at creation and never changes, which is what keeps labels, errors and server responses attached to the right row.

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.

Index names versus id names Two columns comparing index-based input names with stable id-based names for repeating rows. guests[1].email ✗ changes when an earlier row is removed ✗ server errors land on the wrong row ✗ duplicate ids when templates are cloned ✓ readable in network logs guests.7c1e02ab.email ✓ never changes for the life of the row ✓ server errors map by name directly ✓ ids derived from it are unique • opaque in network logs
Index names look tidy and break on the first removal; id names look opaque and never break.

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.

← Back to Dynamic and Repeating Fields