A FormArray of line items looks simple until rows move: rendering with @for (… ; track $index) makes Angular reuse DOM for the wrong row, removeAt shifts every later index so validation messages flicker, and the minimum-count rule shows an error before the user has added anything.
Angular’s FormArray already holds the right thing — control instances whose identity follows the row — so most bugs come from rendering and validation code that reintroduces positions. This page builds a typed repeatable group with stable tracking, safe moves, array-level validators and focus handling. It is the Angular implementation of dynamic field arrays and repeatable groups, within the Angular reactive forms adapters topic.
Context and prerequisites
What a FormArray gives you:
- Ordered controls —
at(i),push,insert,removeAt,clear, andsetControl. - Aggregate state — the array’s
value,statusanderrors, plus array-level validators that see all rows. - Identity — each row is a
FormGroupinstance. Its value, touched state, errors and pending async checks live on the instance, so they move with it when you reorder by moving instances.
The things to avoid: tracking rows by $index, reordering by copying values between controls (which leaves touched and errors behind), and array validators that fire before the user has interacted.
The core pattern: typed rows, identity tracking, instance moves
import { Component, ElementRef, inject, viewChildren } from "@angular/core";
import {
AbstractControl, FormArray, FormGroup, NonNullableFormBuilder, ReactiveFormsModule,
ValidationErrors, ValidatorFn, Validators,
} from "@angular/forms";
type LineItem = FormGroup<{
description: import("@angular/forms").FormControl<string>;
qty: import("@angular/forms").FormControl<number>;
}>;
// Array-level rules: count limits and uniqueness, reported once for the array.
export function rowRules(min: number, max: number): ValidatorFn {
return (ctrl: AbstractControl): ValidationErrors | null => {
const arr = ctrl as FormArray<LineItem>;
if (arr.length < min) return { minRows: { min } };
if (arr.length > max) return { maxRows: { max, extra: arr.length - max } };
const seen = new Set<string>();
for (const g of arr.controls) {
const key = g.controls.description.value.trim().toLowerCase();
if (key && seen.has(key)) return { duplicateRows: true };
seen.add(key);
}
return null;
};
}
@Component({
selector: "app-line-items",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<fieldset [formGroup]="form" aria-describedby="items-error">
<legend>Line items</legend>
@if (showArrayError()) { <p id="items-error">{{ arrayMessage() }}</p> }
<div formArrayName="items">
<!-- track the CONTROL INSTANCE: identity follows the row -->
@for (row of items.controls; track row; let i = $index) {
<fieldset [formGroupName]="i" [attr.data-row]="i">
<legend #rowLegend tabindex="-1">Item {{ i + 1 }} of {{ items.length }}</legend>
<label [for]="'desc-' + i">Description</label>
<input [id]="'desc-' + i" formControlName="description" />
<label [for]="'qty-' + i">Quantity</label>
<input [id]="'qty-' + i" type="number" formControlName="qty" />
<button type="button" (click)="move(i, -1)" [disabled]="i === 0">Move up<span class="visually-hidden"> item {{ i + 1 }}</span></button>
<button type="button" (click)="remove(i)">Remove<span class="visually-hidden"> item {{ i + 1 }}</span></button>
</fieldset>
}
</div>
<button type="button" (click)="add()">Add item</button>
</fieldset>`,
})
export class LineItemsComponent {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({ items: this.fb.array<LineItem>([], { validators: rowRules(1, 20) }) });
get items() { return this.form.controls.items; }
private submitted = false;
private legends = viewChildren<ElementRef<HTMLElement>>("rowLegend");
private newRow(): LineItem {
return this.fb.group({
description: this.fb.control("", Validators.required),
qty: this.fb.control(1, [Validators.required, Validators.min(1)]),
});
}
add() {
this.items.push(this.newRow());
// Focus the new row's first input after it renders.
queueMicrotask(() => document.getElementById(`desc-${this.items.length - 1}`)?.focus());
}
remove(i: number) {
this.items.removeAt(i);
// Focus a neighbour's legend so keyboard users are not dropped on <body>.
queueMicrotask(() => {
const target = this.legends()[Math.min(i, this.items.length - 1)];
target ? target.nativeElement.focus() : document.querySelector<HTMLElement>("button[type=button]:last-of-type")?.focus();
});
}
move(i: number, delta: number) {
const j = i + delta;
if (j < 0 || j >= this.items.length) return;
const row = this.items.at(i);
// Move the INSTANCE: its value, touched, errors and pending checks go with it.
this.items.removeAt(i, { emitEvent: false });
this.items.insert(j, row);
}
showArrayError() { return (this.submitted || this.items.touched) && !!this.items.errors; }
arrayMessage() {
const e = this.items.errors ?? {};
if (e["minRows"]) return "Add at least one item.";
if (e["maxRows"]) return `You can add up to ${e["maxRows"].max} items. Remove ${e["maxRows"].extra} to continue.`;
if (e["duplicateRows"]) return "Each item must have a different description.";
return "";
}
}
Step-by-step walkthrough
- Type the array as
FormArray<LineItem>. A factory (newRow()) builds each row with the same structure and validators, so every row is consistent. - Track by control instance.
track rowin the@forblock (or atrackByreturning the control in*ngFor) ties DOM to the row’s identity — the Angular equivalent of stable keys for reorderable field arrays. - Move instances, not values.
removeAttheninsertwith the same instance carries touched state, errors and pending async validation with the row. - Validate the array as a whole. One array-level validator reports count limits and uniqueness once, rendered on the array’s
fieldset, not on individual rows. - Gate array errors. Show them after submit or after the array is touched, following validating minimum and maximum row counts.
- Manage focus on add and remove. New rows get focus on their first input; removal moves focus to a neighbour’s legend, or to the Add button when the array is empty.
Why the index still appears in the template
The template uses $index for formGroupName, ids and legends even though tracking is by instance. That is safe for the same reason as in other frameworks: those are recomputed on every render from the current position. [formGroupName]="i" re-binds each row’s fieldset to the control now at index i — which, because tracking is by instance, is the same control the DOM was already showing. The one place an index must never be used is anything that persists: a value stored in a map keyed by position, an error collection indexed in parallel to the array, or a track expression.
Failure modes and edge cases
1. track $index
Tracking by index reuses DOM by position, so after a removal the input that had focus now shows the next row’s value, and CSS transitions play on the wrong row. Track by the control.
2. Array validator runs on every keystroke
An array-level validator re-runs whenever any row’s value changes. Keep it cheap (counts and a single pass for duplicates). Expensive cross-row checks belong on submit or in a worker, as in moving heavy validation to a Web Worker.
3. Server errors by index
A 422 naming items[2].qty refers to the order that was sent. Translate with a snapshot of items.controls taken at submit, not the current order — see keeping array errors aligned after reorder and delete — then call setErrors on that instance.
4. getRawValue() for payloads
If rows contain disabled controls (a computed line total), items.value omits them. Use getRawValue() for the payload, as in typed reactive forms in Angular.
5. Patching an array from loaded data
patchValue on a FormArray updates existing controls but does not add or remove them. When loading a record, rebuild the array to the right length first (clear() then push a row per item), then patch.
Verification checklist
Frequently Asked Questions
Should I use FormArray or FormRecord for rows?
Use FormArray when order matters or rows are anonymous (line items). Use FormRecord when rows are keyed by a meaningful id and order does not matter (per-user permissions). FormRecord avoids index translation entirely at the cost of explicit ordering.
Does moving controls re-run validators?
Structural changes update the array’s value and validity, so array-level validators re-run. Row-level validators do not, because the row’s value did not change — which is correct, and another reason to move instances rather than copy values.
How do I support drag-and-drop reordering?
CDK drag-and-drop reports previousIndex and currentIndex; call moveItemInArray on a copy of items.controls or use removeAt/insert with the instance at previousIndex. Keep keyboard reordering available too, as in keyboard reordering of repeatable rows.