Angular FormArray and Dynamic Forms: Add and Remove Fields

Angular Tutorial: Beginner to Advanced · 16. Advanced forms, state and RxJS · 4 min read · Updated

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

  • formArrayName links the template to the array, and [formGroupName]="i" to each row.
  • push and removeAt change the array, and the template updates because @for iterates lines.controls.
  • Validation can sit on one control, a row group, or the array itself, as with the minLines rule.
  • valueChanges is an Observable, and toSignal turns it into a signal so computed() can calculate the total.
  • Always track something 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

  1. Add a "Duplicate line" button that clones a row.
  2. Show the line subtotal beside each row.
  3. Save the invoice in a service and load it back into the form with patchValue and extra push calls.

Quick check

  1. Which two directives link a template to a FormArray row?
  2. How can a validator check the whole array?
  3. 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.

Frequently asked questions

Why does my form lose data when I remove a row?

removeAt deletes that control and its value. Keep a copy if you want an undo feature.

How do I load a saved invoice?

Create as many rows as the saved lines have, then call patchValue or setValue with the saved data.