skip to content

Dynamic Structures

FormArray and FormGroup grow at runtime through push, removeAt, addControl and setControl, rendered with @for and formArrayName. Interviewers ask candidates to build a form from field metadata.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

4

In an Angular reactive order form, how do you let users add and remove line items and bind each row in the template?

level: middleimportance: must knowfreq 68%

answer

  1. a container for repeating rows
  2. one FormGroup per line item
  3. formArrayName, then an index
  4. @for over items.controls

basics

~20 s

Model the line items as a FormArray of FormGroups, add rows with push() and remove them with removeAt(index). In the template, wrap the rows in formArrayName, loop over its controls with @for, and bind each row with [formGroupName]="$index".

solid answer

~40 s

Put an `items` `FormArray` inside the order's `FormGroup`, where each element is a `FormGroup` for one line item with `sku` and `quantity` controls. A small factory method builds a new row, `addItem()` calls `items.push(this.newItem())` and `removeItem(i)` calls `items.removeAt(i)`. Expose the array through a getter such as `get items() { return this.order.controls.items; }`. In the template, an element with `formArrayName="items"` sets the array as the context, `@for (item of items.controls; track item; let i = $index)` repeats a row per control, and `[formGroupName]="i"` binds that row, whose inputs use `formControlName="sku"` and `formControlName="quantity"`. The order's validity, value and submit logic then include every row automatically, and removing a row removes its validators with it.

code

html · 14 lines
html
<form [formGroup]="order" (ngSubmit)="submit()">
  <input formControlName="customer" />
  <div formArrayName="items">
    @for (item of items.controls; track item; let i = $index) {
      <div [formGroupName]="i">
        <input formControlName="sku" placeholder="SKU" />
        <input type="number" formControlName="quantity" />
        <button type="button" (click)="removeItem(i)" [disabled]="items.length === 1">Remove</button>
      </div>
    }
  </div>
  <button type="button" (click)="addItem()">Add item</button>
  <button type="submit" [disabled]="order.invalid">Place order</button>
</form>

go deeper

for a junior

Recall the pieces: a FormArray of FormGroups, push and removeAt, and formArrayName with [formGroupName]="$index" in the template.

for a middle

Explain why one factory builds every row, why the loop iterates controls, and how the wrapper directive scopes the index lookup.

for a senior

Add array-level validators such as a minimum item count, compute totals from getRawValue, and keep row identity stable when rows are removed from the middle.

for a principal

Decide how repeating sections are built across the app so line-item rules live in one factory and stay consistent between forms and the API contract.

## The model An order form in Angular reactive forms has a fixed part (customer, delivery date) and a **repeating** part (line items). The fixed part is a `FormGroup`. The repeating part is a `FormArray` whose elements are identical `FormGroup`s, one per line: ```ts import {Component, inject} from '@angular/core'; import {NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; @Component({ selector: 'app-order-form', imports: [ReactiveFormsModule], templateUrl: './order-form.html', }) export class OrderForm { private readonly fb = inject(NonNullableFormBuilder); readonly order = this.fb.group({ customer: ['', Validators.required], items: this.fb.array([this.newItem()]), }); get items() { return this.order.controls.items; } newItem() { return this.fb.group({ sku: ['', Validators.required], quantity: [1, [Validators.required, Validators.min(1)]], }); } addItem() { this.items.push(this.newItem()); } removeItem(index: number) { this.items.removeAt(index); } } ``` Key decisions in this code: - **One factory** (`newItem()`) builds every row, so the initial row and added rows have identical controls and validators. - **A getter** returns the array from the group. It always reads the current instance, even if the array is later replaced with `setControl()`. - **The builder** is injected in a field initialiser, which is an injection context. ## The template Three directives connect the view to the nested model: 1. `[formGroup]="order"` on the `<form>` binds the root. 2. `formArrayName="items"` on a wrapper element makes the `items` array the context for everything inside. 3. `[formGroupName]="i"` on each row binds the row's `FormGroup` by its **index** in that array; its inputs use `formControlName`. ```html <form [formGroup]="order"> <input formControlName="customer" /> <div formArrayName="items"> @for (item of items.controls; track item; let i = $index) { <div [formGroupName]="i"> <input formControlName="sku" /> <input type="number" formControlName="quantity" /> <button type="button" (click)="removeItem(i)">Remove</button> </div> } </div> <button type="button" (click)="addItem()">Add item</button> </form> ``` The loop iterates `items.controls`, the array of child controls, not `items.value`. Tracking by the control object keeps each DOM row attached to its own control when a row in the middle is removed. The buttons have `type="button"` so they do not submit the form. ## What you get for free | Concern | Behaviour | |---|---| | Validity | The order is invalid while any row is invalid | | Value | `order.value.items` is an array of row values | | Removal | `removeAt(i)` drops the row's controls and validators | | Submit | `order.getRawValue()` contains every row | A line total or order total can be computed from `items.getRawValue()` in a method or a derived value, without extra state. ## Submitting the order On submit, the whole tree is available at once: - If `order.invalid`, call `order.markAllAsTouched()` so every row's error messages appear, including rows the user never focused. - Otherwise map `order.getRawValue()` to the API's payload. It is an object with `customer` and an `items` array, one entry per row, in row order. - After a successful save, reset deliberately: `items.clear()` and one `addItem()` restores the blank order, because `order.reset()` resets the existing rows' values but keeps however many rows there were. This is also the place to strip rows the user left completely empty, if the product allows that, rather than making every blank row invalid. ## Variations interviewers ask about - **A list of plain values** (for example promo codes): use `FormArray<FormControl<string>>` and bind each input with `[formControlName]="i"` directly inside `formArrayName`. - **A minimum number of rows**: attach a validator to the array itself that checks `length`, or disable the Remove button when `items.length === 1`. - **A root-level array**: since Angular 21, the `[formArray]` directive can bind a `FormArray` that has no enclosing `FormGroup`. ## Common mistakes 1. Iterating `items.value` and binding with `formControlName`, which renders values but loses the connection to the controls. 2. Forgetting the `formArrayName` wrapper, so `[formGroupName]="i"` looks for a group named `0` on the root. 3. Building added rows differently from the initial rows, so validators disagree between them. 4. Leaving the row buttons as the default `type="submit"`, so "Add item" submits the order.

  • Why does the loop iterate items.controls rather than items.value?
    The template needs one row per control so each row can bind to that control through `[formGroupName]="i"`. `items.value` is a plain array of data and omits disabled rows, so its indices can drift from the controls. Iterating `controls` keeps the view and the model aligned.
  • How would you require at least one line item?
    Attach a validator to the `FormArray`, for example one that returns `{minItems: true}` when `control.length` is zero, through `this.fb.array([...], {validators: [...]})`. The order is invalid while the array is empty. Disabling the Remove button on the last row prevents reaching that state from the UI.
  • What does the formArrayName wrapper actually do?
    It registers the named `FormArray` as the parent context for the directives inside it. Without it, `[formGroupName]="i"` resolves the index against the root `FormGroup` and fails to find a group named `0`. With it, the index is looked up in the `items` array.

saying these in an interview costs you the question

  • Loop over items.value and bind rows with formControlName.
  • Push new rows into items.controls with the array's native push.
  • [formGroupName] only accepts string names, not indices.
  • A FormArray row needs its own separate [formGroup] form to validate.
  • A row removed with removeAt still counts toward the order's validity.
open as a page

In Angular reactive forms, which FormArray methods add, insert, replace and remove controls, and what does each one do to the array?

level: juniorimportance: should knowfreq 52%

basics

~10 s

A FormArray changes shape through push(), insert(index, c), removeAt(index), setControl(index, c) and clear(). Each one updates the array's value and validity and emits valueChanges unless emitEvent is false, but none marks the array dirty.

open as a page

In Angular, why does patchValue on an order form with an empty items FormArray drop a saved order's line items, and how do you load them?

level: middleimportance: should knowfreq 46%

basics

~20 s

patchValue and setValue only write values into controls that already exist, and a FormArray never creates rows from data. Rebuild the array first, with clear() and push() per line or setControl('items', newArray), then patch or set the values.

open as a page

How would you build an Angular reactive form from server-supplied field metadata, including fields that appear only when another field has a certain value?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Map each metadata entry to a FormControl with validators derived from it, add them to a group, and render with @for plus @switch on the field type using [formControlName]. Handle conditional fields with addControl/removeControl, or disable() to keep their values.

open as a page