Dynamic and Repeating Fields
Static forms have a fixed set of inputs, and validation code can find them once and attach listeners. Real forms often do not: an invoice has a variable number of line items, a job application lets people add past employers, a booking takes one traveller or six, a survey reveals follow-up questions based on earlier answers. Every one of those runtime changes breaks naive validation in a different way. Listeners attached at page load never reach new rows. Errors keyed by index point at the wrong row after a removal. A “minimum one item” rule has no single input to attach to. Focus disappears when a row with an error is deleted. Server errors for items[3] arrive after the user has reordered the list. This topic covers validating forms whose structure changes at runtime, built on the site’s <form novalidate> plus manual reportValidity() baseline and the Constraint Validation API, which already handles any number of inputs — as long as the code around it does.
The failure this topic addresses is validation that works in the demo with one row and quietly breaks in production with five. The fixes are not complicated, but they have to be designed in from the first row: stable identities, delegated listeners, a home for list-level errors, and deliberate focus handling.
Prerequisites for Dynamic Forms
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| TypeScript | 5.0+ | Typed row models |
<template> element |
All browsers | Cloning row markup without string HTML |
crypto.randomUUID() |
Chrome 92, Firefox 95, Safari 15.4 | Stable row identifiers |
Event delegation (focusout, input) |
All browsers | Validating rows added after load |
form.elements.namedItem() |
All browsers | Resolving dotted names to inputs |
| Schema library with arrays | Zod, Valibot, or equivalent | List-level and nested validation |
Dynamic Field API Reference
| API | Type | Use | Notes |
|---|---|---|---|
template.content.cloneNode(true) |
DocumentFragment |
Create a row | Keeps markup in HTML, not strings |
crypto.randomUUID() |
string |
Row identity | Put it in names and ids |
form.addEventListener("focusout", …) |
delegated | Touched state for any field | blur does not bubble |
form.addEventListener("input", …) |
delegated | Live re-checks | Covers future rows automatically |
form.elements |
HTMLFormControlsCollection |
Current controls | Always reflects added and removed rows |
fieldset.setCustomValidity() |
n/a | — | Fieldsets have no custom validity; attach list errors to a control |
MutationObserver |
observer | React to rows added by other code | Rarely needed with delegation |
z.array(schema).min(n) |
schema | List rules | Pair with a visible, focusable list error |
Step-by-Step Implementation
1. Define the row in a template with a placeholder for its id
<form id="invoice" novalidate>
<fieldset id="items" aria-describedby="items-err">
<legend>Line items</legend>
<ol id="item-list"></ol>
<p id="items-err" class="field-error" hidden tabindex="-1"></p>
<button type="button" id="add-item">Add line item</button>
</fieldset>
<button type="submit">Send invoice</button>
</form>
<template id="item-row">
<li class="item-row" data-row-id="">
<label>Description <input name="items.ID.description" required maxlength="120"></label>
<label>Quantity <input name="items.ID.qty" type="number" inputmode="numeric" required min="1" max="999" step="1"></label>
<label>Unit price <input name="items.ID.price" inputmode="decimal" required pattern="\d{1,6}(\.\d{1,2})?"></label>
<button type="button" class="remove-item">Remove</button>
</li>
</template>
2. Add and remove rows with stable identities
const form = document.querySelector<HTMLFormElement>("#invoice")!;
const list = form.querySelector<HTMLOListElement>("#item-list")!;
const template = document.querySelector<HTMLTemplateElement>("#item-row")!;
export function addRow(): HTMLLIElement {
const id = crypto.randomUUID().slice(0, 8);
const row = (template.content.cloneNode(true) as DocumentFragment).firstElementChild as HTMLLIElement;
row.dataset.rowId = id;
for (const el of row.querySelectorAll<HTMLInputElement>("[name]")) {
el.name = el.name.replace("ID", id);
el.id = el.name.replace(/\./g, "-");
el.setAttribute("aria-describedby", `${el.id}-err`);
el.insertAdjacentHTML("afterend", `<span id="${el.id}-err" class="field-error" hidden></span>`);
}
row.querySelector<HTMLButtonElement>(".remove-item")!.setAttribute("aria-label", `Remove line item ${list.children.length + 1}`);
list.append(row);
row.querySelector<HTMLInputElement>("input")!.focus(); // move focus into the new row
return row;
}
list.addEventListener("click", (event) => {
const button = (event.target as Element).closest(".remove-item");
if (!button) return;
const row = button.closest<HTMLLIElement>(".item-row")!;
const next = (row.nextElementSibling ?? row.previousElementSibling) as HTMLLIElement | null;
row.remove();
// Keep focus somewhere sensible: the neighbouring row, else the add button.
(next?.querySelector<HTMLInputElement>("input") ?? form.querySelector<HTMLButtonElement>("#add-item")!).focus();
validateList();
});
form.querySelector("#add-item")!.addEventListener("click", () => { addRow(); validateList(); });
Names like items.3f9a1c2e.qty rather than items[2].qty are the key decision. An index changes whenever a row above is removed; an id never does. Every later step — error mapping, server responses, focus — depends on that stability. The row mechanics are covered in depth in validating dynamically added form rows.
3. Validate every row through delegated listeners
function showFieldError(el: HTMLInputElement): void {
const out = document.getElementById(`${el.id}-err`);
const invalid = !el.validity.valid;
el.toggleAttribute("aria-invalid", invalid);
if (out) { out.textContent = invalid ? el.validationMessage : ""; out.hidden = !invalid; }
}
// focusout bubbles, so one listener handles rows added at any time.
form.addEventListener("focusout", (e) => {
const el = e.target as HTMLInputElement;
if (el.name?.startsWith("items.")) showFieldError(el);
});
form.addEventListener("input", (e) => {
const el = e.target as HTMLInputElement;
if (el.hasAttribute("aria-invalid")) showFieldError(el); // clear once fixed
});
4. Add list-level rules and submit through the canonical path
const listError = form.querySelector<HTMLElement>("#items-err")!;
function validateList(): boolean {
const rows = list.querySelectorAll(".item-row");
const descriptions = [...list.querySelectorAll<HTMLInputElement>("[name$='.description']")].map((i) => i.value.trim().toLowerCase());
let message = "";
if (rows.length === 0) message = "Add at least one line item.";
else if (rows.length > 50) message = "An invoice can have up to 50 line items.";
else if (new Set(descriptions.filter(Boolean)).size !== descriptions.filter(Boolean).length) message = "Each line item needs a different description.";
listError.textContent = message;
listError.hidden = !message;
return !message;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const listOk = validateList();
const fieldsOk = form.reportValidity(); // focuses the first invalid row field
if (!listOk && fieldsOk) listError.focus(); // list errors have no input to focus
if (listOk && fieldsOk) form.submit();
});
A list-level rule like “at least one item” has no input to own it — there may be no rows at all. Render it in a focusable message element referenced by the fieldset’s aria-describedby, and move focus to that element when it is the only problem, since reportValidity() cannot focus something that is not a form control.
State Management and Edge Cases
Dynamic forms have more state than their inputs show: the order of rows, which rows the user has touched, and errors from the server that refer to rows by identity.
- Server errors by row id. When the server returns
items.3f9a1c2e.qty, map it withform.elements.namedItem(key); the row may have moved, but its name has not. If the row was removed while the request was in flight, the lookup returnsnulland the error belongs in the form-level summary, as described in mapping server field errors to form inputs. - Reordering. Drag-and-drop or up/down buttons change order but not names; announce the new position through a status region (“Line item moved to position 2”).
- Conditional sub-fields. A row whose “type” select reveals different inputs should add and remove those inputs, not hide them, or disable hidden ones — disabled controls are excluded from validation and submission.
- Rows added by other code. Integrations sometimes insert rows without going through
addRow— a paste-from-spreadsheet handler, a template picker. Route every insertion through one function that assigns ids and ARIA wiring; if that is impossible, aMutationObserveron the list can normalise rows as they appear. - Totals and cross-row rules. Rules like “total must not exceed the budget” depend on every row; recompute them on any row’s
input, and attach the error to the total’s display plus the list message.
Accessibility Compliance for Dynamic Fields
Adding and removing content is where dynamic forms most often fail users of assistive technology. Focus management (WCAG 2.4.3): after adding a row, move focus into its first input; after removing one, move focus to the neighbouring row or the add button — never leave it on a removed element, where it falls back to the document body. Unique names for repeated controls (WCAG 2.4.6 and 4.1.2): every row’s “Remove” button needs an accessible name that says which row, and every row should have a heading or a numbered list item so users know where they are. Status messages (WCAG 4.1.3): announce additions, removals and moves politely (“Line item 3 added”) so screen reader users know the structure changed. And list-level errors must be reachable: a focusable message tied to the group with aria-describedby and included in any error summary.
const status = document.querySelector<HTMLElement>("#list-status")!; // role="status"
function announce(message: string): void {
status.textContent = "";
requestAnimationFrame(() => (status.textContent = message)); // re-announce identical text
}
Common Gotchas and Debugging
Listeners attached at load. document.querySelectorAll("input").forEach(el => el.addEventListener(...)) misses every row added later.
// Before: only rows present at load are validated
form.querySelectorAll("input").forEach((el) => el.addEventListener("blur", check));
// After: one delegated listener covers current and future rows
form.addEventListener("focusout", (e) => check(e.target as HTMLInputElement));
Index-based names. items[2].qty refers to a different row after a removal; errors and server responses land on the wrong line. Use stable ids.
Duplicate ids from cloned templates. Cloning a template that contains id="qty" creates duplicates, breaking label for and aria-describedby. Derive every id from the row id.
Hidden rows still validated. Hiding a row with CSS leaves its required inputs in form.elements, blocking submission invisibly. Remove the row, or set disabled on a hidden fieldset.
reportValidity() cannot focus list errors. When the only problem is a list rule, focus the list’s error message element yourself.
Framework Integration for Field Arrays
Every major form library models repeating rows as a field array, and each has the same identity problem this topic solves with stable ids. React Hook Form’s useFieldArray generates an id per item for use as the React key (never use the index); VeeValidate’s useFieldArray exposes fields with a key; Angular’s FormArray holds controls whose identity survives reordering. The validation principles carry over unchanged: per-item rules in the item schema, list rules on the array (z.array(item).min(1)), and errors mapped by identity. The React-specific recipe is validating React Hook Form field arrays.
import { z } from "zod";
export const Item = z.object({
description: z.string().trim().min(1, "Describe this line item."),
qty: z.coerce.number().int().min(1, "Quantity must be at least 1.").max(999),
price: z.string().regex(/^\d{1,6}(\.\d{1,2})?$/, "Enter a price like 12.50."),
});
export const Invoice = z.object({
items: z.record(z.string(), Item) // keyed by row id, not index
.refine((rows) => Object.keys(rows).length >= 1, "Add at least one line item.")
.refine((rows) => Object.keys(rows).length <= 50, "An invoice can have up to 50 line items."),
});
Modelling rows as a record keyed by id, rather than an array, keeps schema paths (items.3f9a1c2e.qty) identical to input names, so errors from the schema map onto inputs with no translation. Nested structures are covered in validating nested object fields.
Conditional Sub-Fields Inside Rows
Rows often change shape: a line item’s “type” select switches between a product (quantity and unit price) and a service (hours and rate); a traveller row adds passport fields for international trips. Validation must follow the shape, and the most reliable way is to add and remove the sub-fields rather than toggling their visibility. When a sub-field must stay in the DOM — to preserve a value the user might switch back to — wrap it in a <fieldset> and set disabled on it while inactive, which excludes every control inside from constraint validation and from submission. The schema mirrors the choice with a discriminated union on the type field, so the same rows validate identically on the server. The general reveal-and-validate pattern is covered in conditional field validation on selection.
const Row = z.discriminatedUnion("type", [
z.object({ type: z.literal("product"), qty: z.coerce.number().int().min(1), price: z.string().min(1) }),
z.object({ type: z.literal("service"), hours: z.coerce.number().positive(), rate: z.string().min(1) }),
]);
row.querySelector("select[name$='.type']")!.addEventListener("change", (e) => {
const kind = (e.target as HTMLSelectElement).value;
for (const group of row.querySelectorAll<HTMLFieldSetElement>("fieldset[data-kind]")) {
group.disabled = group.dataset.kind !== kind; // disabled groups skip validation
group.hidden = group.disabled;
}
});
Validating Repeating Data on the Server
The server receives repeating rows as flat FormData keys — items.3f9a1c2e.qty, items.3f9a1c2e.price — and must rebuild the structure before validating. A converter that splits keys on dots and nests the values produces the record-keyed shape the schema expects, as shown in validating FormData on the server with Zod. Two server-only checks matter for lists. Enforce the row limit before parsing, by counting distinct row ids in the keys, so a crafted request with ten thousand rows is rejected cheaply. And never trust row ids as database identifiers: they are client-generated labels for mapping errors, not keys into your tables. Existing records should be referenced by a separate, server-issued id field that the server checks for ownership.
Testing Dynamic Forms
Dynamic behaviour is where unit tests are weakest and browser tests earn their keep. A compact Playwright suite should add rows, remove a row with an error, reorder rows, and submit — asserting that errors land on the right rows, that focus is never lost, and that removed rows stop blocking submission.
import { test, expect } from "@playwright/test";
test("removing an invalid row unblocks submission and keeps focus", async ({ page }) => {
await page.goto("/invoice");
await page.getByRole("button", { name: "Add line item" }).click();
await page.getByRole("button", { name: "Add line item" }).click();
const rows = page.locator(".item-row");
await rows.nth(0).getByLabel("Description").fill("Design work");
await rows.nth(0).getByLabel("Quantity").fill("2");
await rows.nth(0).getByLabel("Unit price").fill("450.00");
await page.getByRole("button", { name: "Send invoice" }).click();
await expect(rows.nth(1).getByLabel("Description")).toBeFocused(); // row 2 is empty → invalid
await page.getByRole("button", { name: "Remove line item 2" }).click();
await expect(rows.nth(0).getByLabel("Description")).toBeFocused(); // focus moves to the neighbouring row
await expect(rows).toHaveCount(1);
});
Undo Instead of Confirmation for Removal
Removing a row with data in it is destructive, and the reflex is a confirmation dialog: “Remove this line item?” Dialogs interrupt every removal to protect against the rare mistake, and they are easy to get wrong for keyboard and screen reader users. An undo affordance is usually better: remove the row immediately, keep its values in memory, and show a short status message with an “Undo” button that restores the row in place with the same id — so any errors, server references and focus logic still line up. Keep the message visible long enough to act on (and do not auto-dismiss it for users who need more time, per WCAG 2.2.1), announce it politely, and clear the stored row on submit. Restoring with the same id is what makes undo safe for validation: the row’s inputs reappear with identical names, so a server error that referred to it maps back correctly even if it arrives after the undo.
Performance With Many Rows
Forms with hundreds of rows — bulk editors, spreadsheets-in-a-form — need care. Delegated listeners scale to any number of rows, but re-validating every row on every keystroke does not: validate only the row being edited on input, recompute cross-row rules (uniqueness, totals) with incremental data structures, and run the full pass only on submit. Rendering matters too; appending hundreds of rows with individual append calls causes repeated layout, so batch them in a DocumentFragment. For very large lists, virtualise rendering and validate the data model rather than the DOM, applying custom validity only to rows that are currently rendered.
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
<template> + cloneNode |
Yes | Yes | Yes | — |
crypto.randomUUID() |
92+ | 95+ | 15.4+ | Fallback: counter + timestamp |
focusout delegation |
Yes | Yes | Yes | Bubbles, unlike blur |
form.elements.namedItem with dotted names |
Yes | Yes | Yes | Names are opaque strings |
:has() for row styling |
105+ | 121+ | 15.4+ | Style rows that contain an invalid field |
Frequently Asked Questions
How do I validate form fields that are added dynamically?
Use event delegation on the form — focusout and input bubble — so one listener handles every current and future field, and rely on form.reportValidity(), which always sees the current set of controls.
Should repeating rows use index-based names?
No. Indexes change when rows are removed or reordered, so errors and server responses land on the wrong row. Give each row a stable id and put it in the input names, such as items.3f9a1c2e.qty.
How do I show a "minimum one item" error?
Render it in a focusable message element tied to the list's fieldset with aria-describedby, and move focus to it on submit when it is the only problem, because reportValidity can only focus form controls.
What should happen to focus when a row is removed?
Move it to the same field in the neighbouring row, or to the add button if the list is empty, and announce the removal in a status region. Never leave focus on the removed element.
Related Guides
- Validating Dynamically Added Form Rows — rows, ids and focus in depth.
- Validating Nested Object Fields — dotted names and nested schemas.
- Validating React Hook Form Field Arrays — the same problem in React Hook Form.
- Cross-Field Validation Strategies — rules that span several fields or rows.
← Back to Advanced JavaScript Validation Logic & Patterns