Form Arrays
Most reactive forms have a fixed shape, but real applications often need variable-length collections: a list of phone numbers, an order with any number of line items, or a survey where users add as many answers as they like. FormArray is the building block for these scenarios. It holds an ordered list of controls — FormControl, FormGroup, or even nested FormArray instances — that you can grow and shrink at runtime while keeping validation, value tracking, and dirty state intact.
What is a FormArray?
A FormArray is one of the three reactive form building blocks alongside FormControl and FormGroup. Where a FormGroup aggregates controls by name (an object), a FormArray aggregates them by index (an array). Its value is therefore a plain JavaScript array, and its validity is the aggregate of every child control’s validity.
The key methods you use to mutate an array are push(), insert(), removeAt(), and clear(). Because these change the form’s structure, the template re-renders automatically when you iterate the array’s controls.
| Building block | Aggregates by | Value shape |
|---|---|---|
FormControl | n/a (single value) | primitive |
FormGroup | name | object |
FormArray | index | array |
Building a form with a FormArray
The cleanest way to construct nested controls is with the FormBuilder (or its typed cousin NonNullableFormBuilder). The inject() function keeps the component constructor free of boilerplate.
import { Component, inject } from '@angular/core';
import {
FormArray,
FormGroup,
FormControl,
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@Component({
selector: 'app-order-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './order-form.component.html',
})
export class OrderFormComponent {
private fb = inject(NonNullableFormBuilder);
orderForm = this.fb.group({
customer: ['', Validators.required],
items: this.fb.array<FormGroup>([this.createItem()]),
});
private createItem(): FormGroup {
return this.fb.group({
name: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
get items(): FormArray {
return this.orderForm.controls.items;
}
addItem(): void {
this.items.push(this.createItem());
}
removeItem(index: number): void {
this.items.removeAt(index);
}
}
The items getter is a common and important pattern: templates cannot index into controls cleanly, so exposing a strongly-named getter keeps the markup readable and lets you cast to FormArray once.
Expose your
FormArraythrough a getter rather than reaching intoform.get('items')in the template. The getter is typed, it avoids repeated null checks, and it gives you a single place to cast.
Rendering and iterating in the template
Bind the outer form with [formGroup], the array with formArrayName, and each iterated group with [formGroupName]="i". Use the modern @for control flow with a track expression so Angular can reconcile rows efficiently when items are added or removed.
<form [formGroup]="orderForm">
<label>
Customer
<input formControlName="customer" />
</label>
<section formArrayName="items">
@for (item of items.controls; track item; let i = $index) {
<fieldset [formGroupName]="i">
<input formControlName="name" placeholder="Item name" />
<input type="number" formControlName="quantity" />
<button type="button" (click)="removeItem(i)">Remove</button>
</fieldset>
}
</section>
<button type="button" (click)="addItem()">Add item</button>
</form>
Tracking by the control reference (track item) is ideal here because each FormGroup is a stable object — Angular keeps the existing DOM and form state when the list reorders, rather than rebuilding rows by index.
Reading and patching the value
The array’s value is a plain array, so you consume it like any other collection. To populate an existing form (for example when editing a saved order), clear() the array first, then push() a group per record.
loadOrder(saved: { customer: string; items: { name: string; quantity: number }[] }): void {
this.items.clear();
for (const line of saved.items) {
this.items.push(this.fb.group({
name: [line.name, Validators.required],
quantity: [line.quantity, [Validators.required, Validators.min(1)]],
}));
}
this.orderForm.patchValue({ customer: saved.customer });
}
submit(): void {
console.log(this.orderForm.getRawValue());
}
Output:
{
customer: 'Ada Lovelace',
items: [
{ name: 'Analytical Engine', quantity: 1 },
{ name: 'Punch cards', quantity: 500 }
]
}
Note that patchValue and setValue also accept arrays, but they only update existing controls — they will not add or remove rows. Use clear() plus push() whenever the number of items changes.
Validating the array as a whole
You can attach a validator to the FormArray itself to enforce constraints on the collection — such as requiring at least one item.
import { AbstractControl, ValidationErrors } from '@angular/forms';
export function minLength(min: number) {
return (control: AbstractControl): ValidationErrors | null => {
const array = control as FormArray;
return array.length >= min ? null : { minItems: { required: min, actual: array.length } };
};
}
// items: this.fb.array<FormGroup>([this.createItem()], minLength(1)),
The error surfaces on the array control, so read it via items.errors?.['minItems'] in the template.
Best practices
- Always expose the
FormArraythrough a typed getter; never index intocontrolsdirectly from the template. - Build child controls in a small factory method (e.g.
createItem()) so adding and editing stay consistent. - Use
@forwithtrack item(the control reference) so rows keep their DOM and state across reorders. - Reset a list with
clear()thenpush()—patchValue/setValuecannot change the number of controls. - Attach a validator to the array to enforce collection-level rules like a minimum number of entries.
- Prefer
NonNullableFormBuilderfor predictable, non-null value types across the whole structure.