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, and setControl.
  • Aggregate state — the array’s value, status and errors, plus array-level validators that see all rows.
  • Identity — each row is a FormGroup instance. 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.

Two ways to move row 3 to the top Copying values with patchValue from row three to row one moves only the values; touched flags, errors, pending async validation and the DOM tracked by index stay at their old positions, attached to the wrong rows. Moving the control instance with removeAt and insert moves everything the row owns, and with tracking by control identity the DOM and focus move with it. What moves Copy values (patchValue) Move the instance values yes yes touched / dirty stay at old index yes errors, pending checks stay at old index yes DOM and focus (track by control) wrong row yes

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

  1. Type the array as FormArray<LineItem>. A factory (newRow()) builds each row with the same structure and validators, so every row is consistent.
  2. Track by control instance. track row in the @for block (or a trackBy returning the control in *ngFor) ties DOM to the row’s identity — the Angular equivalent of stable keys for reorderable field arrays.
  3. Move instances, not values. removeAt then insert with the same instance carries touched state, errors and pending async validation with the row.
  4. 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.
  5. Gate array errors. Show them after submit or after the array is touched, following validating minimum and maximum row counts.
  6. 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.

Removing a middle row, done right The user activates Remove on item two. removeAt removes that row's FormGroup instance, taking its value, errors and pending checks with it. Rows one and three keep their instances and state. Because the template tracks by instance, the DOM for row three is reused and simply renumbered to item two of two. Focus moves to that row's legend, and a polite live region announces that item two was removed. Remove item 2 items.removeAt(1) Its instance, value and errors are gone with it. Other rows unchanged Instances 1 and 3 keep their state. track row: DOM for old row 3 is reused, not rebuilt. Renumber and refocus "Item 2 of 2" legend focused. Announced politely; undo offered if supported.

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.

FormArray operations and what they preserve push adds a row at the end and leaves every other row untouched. insert adds a row at a position and shifts later indices but keeps their instances. removeAt removes one instance and shifts later indices while keeping their instances. Moving by removeAt and insert of the same instance carries the row's full state. setControl replaces the instance at an index and discards the old row's state, so use it only to reset a row deliberately. patchValue changes values of existing rows but never adds or removes rows. Operation Other rows' state Use it to push(row) untouched add at the end insert(i, row) instances kept add at a position removeAt(i) instances kept delete one row removeAt + insert (same instance) instances kept move a row with all its state setControl(i, row) replaced row's state lost reset one row on purpose patchValue([...]) values only; no add/remove load values after rebuilding length

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.


Related

← Angular Reactive Forms Adapters