Some forms have a repeating part: invoice lines, phone numbers, ingredients. The number of rows changes while the user types. Angular's FormArray holds a list of controls or groups you can add and remove at run time.
What you will learn
- How to build and render a
FormArray - How to add, remove and validate rows
- How to generate fields from configuration
An invoice form
// src/app/forms/invoice-form.ts
import { CurrencyPipe } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-invoice-form',
imports: [ReactiveFormsModule, CurrencyPipe],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<p>
<label for="client">Client</label><br />
<input id="client" formControlName="client" />
</p>
<h3>Lines</h3>
<div formArrayName="lines">
@for (line of lines.controls; track line; let i = $index) {
<div [formGroupName]="i" class="row">
<input formControlName="description" aria-label="Description" placeholder="Description" />
<input formControlName="quantity" type="number" min="1" aria-label="Quantity" />
<input formControlName="price" type="number" min="0" aria-label="Unit price" />
<button type="button" (click)="removeLine(i)" [attr.aria-label]="'Remove line ' + (i + 1)">Remove</button>
</div>
}
</div>
@if (lines.hasError('minLines')) {
<p role="alert">Add at least one line.</p>
}
<button type="button" (click)="addLine()">Add line</button>
<p><strong>Total: {{ total() | currency }}</strong></p>
<button type="submit">Save invoice</button>
</form>
`,
styles: `.row { display: flex; gap: .5rem; margin: .25rem 0; }`,
})
export class InvoiceForm {
private readonly fb = inject(FormBuilder).nonNullable;
protected readonly form = this.fb.group({
client: ['', Validators.required],
lines: this.fb.array(
[this.newLine()],
(control) => ((control as FormArray).length > 0 ? null : { minLines: true }),
),
});
protected get lines(): FormArray {
return this.form.controls.lines;
}
// Convert the value stream to a signal so the total updates in the template.
private readonly value = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() });
protected readonly total = computed(() =>
(this.value().lines ?? []).reduce((sum, l) => sum + (l.quantity ?? 0) * (l.price ?? 0), 0),
);
private newLine() {
return this.fb.group({
description: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0)]],
});
}
protected addLine(): void {
this.lines.push(this.newLine());
}
protected removeLine(index: number): void {
this.lines.removeAt(index);
}
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log('Invoice', this.form.getRawValue());
}
}
// src/app/app.ts
import { Component } from '@angular/core';
import { InvoiceForm } from './forms/invoice-form';
@Component({
imports: [InvoiceForm],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Invoice</h1>
<app-invoice-form />
</main>
The key parts
formArrayNamelinks the template to the array, and[formGroupName]="i"to each row.pushandremoveAtchange the array, and the template updates because@foriterateslines.controls.- Validation can sit on one control, a row group, or the array itself, as with the
minLinesrule. valueChangesis an Observable, andtoSignalturns it into a signal socomputed()can calculate the total.- Always
tracksomething stable. The control object itself works as a key for form rows. - Because
form.getRawValue()includes disabled controls, use it when you must save everything.
Generate fields from configuration
When forms come from a server or a CMS, build the controls from data:
// src/app/forms/dynamic-form.ts
import { Component, inject, input, OnInit } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
export interface FieldConfig {
name: string;
label: string;
type: 'text' | 'email' | 'number';
required?: boolean;
}
@Component({
selector: 'app-dynamic-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
@for (f of fields(); track f.name) {
<p>
<label [for]="f.name">{{ f.label }}</label><br />
<input [id]="f.name" [type]="f.type" [formControlName]="f.name" />
@if (form.get(f.name)?.touched && form.get(f.name)?.invalid) {
<span role="alert">{{ f.label }} is invalid.</span>
}
</p>
}
<button type="submit">Send</button>
</form>
`,
})
export class DynamicForm implements OnInit {
private readonly fb = inject(FormBuilder);
readonly fields = input.required<FieldConfig[]>();
protected readonly form = this.fb.group({});
ngOnInit(): void {
for (const f of this.fields()) {
const validators = [];
if (f.required) {
validators.push(Validators.required);
}
if (f.type === 'email') {
validators.push(Validators.email);
}
this.form.addControl(f.name, this.fb.control('', validators));
}
}
protected submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log(this.form.getRawValue());
}
}
The form object is created once and controls are added in ngOnInit, when the fields input is available. Dynamic forms trade type safety for flexibility, so prefer a typed, hand-written form when the fields are known.
Try it yourself
- Add a "Duplicate line" button that clones a row.
- Show the line subtotal beside each row.
- Save the invoice in a service and load it back into the form with
patchValueand extrapushcalls.
Quick check
- Which two directives link a template to a
FormArrayrow? - How can a validator check the whole array?
- How do you calculate a total that updates as the user types?
Next, continue with Angular Signal Forms: Build Forms with Signals in Angular 22.