Dynamic Forms — FormArray and Programmatic Control Management

FormArray manages a dynamic list of form controls — adding and removing controls at runtime in response to user actions. The post form’s tags are a classic FormArray use case: the user starts with one tag input and can add more or remove any. Unlike a fixed FormGroup, FormArray’s length is not known at form creation time. Angular 14+’s typed forms give FormArray a typed parameter: FormArray<FormControl<string>> means each element is a FormControl of type string.

FormArray — Dynamic Tags

import { FormBuilder, FormArray, FormControl,
         Validators, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector:   'app-post-form',
  standalone:  true,
  imports:    [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">

      <!-- Static fields ───────────────────────────────────────────────── -->
      <input formControlName="title">
      <textarea formControlName="body"></textarea>

      <!-- ── FormArray section ─────────────────────────────────────────── -->
      <div formArrayName="tags">  <!-- bind to the FormArray by name ──── -->
        <h3>Tags</h3>

        @for (ctrl of tagsArray.controls; track $index; let i = $index) {
          <div class="tag-row">
            <input [formControlName]="i"  <!-- index as the control name ── -->
                   placeholder="Tag name"
                   [class.is-invalid]="ctrl.invalid && ctrl.touched">

            @if (ctrl.hasError('maxlength')) {
              <span class="error">Max 50 characters.</span>
            }

            <button type="button" (click)="removeTag(i)"
                    [disabled]="tagsArray.length === 1">×</button>
          </div>
        }

        <button type="button" (click)="addTag()"
                [disabled]="tagsArray.length >= 10">
          + Add Tag
        </button>
      </div>

      <button type="submit" [disabled]="form.invalid">Save Post</button>
    </form>
  `,
})
export class PostFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.nonNullable.group({
    title: ['', [Validators.required, Validators.minLength(5)]],
    body:  ['', [Validators.required, Validators.minLength(50)]],
    // Typed FormArray of string controls
    tags:  this.fb.array([
      this.fb.nonNullable.control('', Validators.maxLength(50)),  // start with one
    ]),
  });

  // Typed accessor — cleaner than form.get('tags') as FormArray
  get tagsArray(): FormArray<FormControl<string>> {
    return this.form.controls.tags;
  }

  addTag(): void {
    if (this.tagsArray.length < 10) {
      this.tagsArray.push(this.fb.nonNullable.control('', Validators.maxLength(50)));
    }
  }

  removeTag(index: number): void {
    if (this.tagsArray.length > 1) {
      this.tagsArray.removeAt(index);
    }
  }

  onSubmit(): void {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }
    const data = this.form.getRawValue();
    // data.tags is string[] — non-empty tags only
    const tags = data.tags.filter(t => t.trim().length > 0);
    console.log('Submitting:', { ...data, tags });
  }
}
Note: In the template, formArrayName="tags" creates a binding to the FormArray. Inside it, [formControlName]="i" uses the array index as the control identifier. The variable i from the @for loop provides the index. This pattern — formArrayName + index-based formControlName — is the correct way to bind FormArray controls in templates. Do not use formControlName directly outside formArrayName context for array elements.
Tip: Use a typed accessor getter (get tagsArray()) to avoid casting form.get('tags') as FormArray repeatedly. The getter returns the strongly typed FormArray<FormControl<string>>, and TypeScript knows exactly what type tagsArray.controls[i].value is. This is much cleaner than the unsafe string-based access pattern. Similarly, for nested FormGroups, create getters: get addressGroup() { return this.form.controls.address; }.
Warning: FormArray.controls is a reference — iterating it in a template with @for does not automatically update when controls are added or removed. Angular’s change detection handles this for OnPush components because pushing to a FormArray triggers change detection. However, if you are experiencing display issues after add/remove, verify that your FormArray operations go through Angular’s methods (push(), removeAt(), insert()) rather than directly modifying the underlying array.

Common Mistakes

Mistake 1 — Accessing FormArray without a typed getter (runtime errors from as FormArray casts)

❌ Wrong — (form.get('tags') as FormArray).push(...) — unsafe cast, no type inference.

✅ Correct — typed getter: get tagsArray(): FormArray<FormControl<string>> { return this.form.controls.tags; }

Mistake 2 — Using formControlName without formArrayName context (template error)

❌ Wrong — [formControlName]="i" outside a formArrayName context; Angular cannot find the control.

✅ Correct — always wrap array control bindings inside a formArrayName parent.

🧠 Test Yourself

A user adds 3 tags, then removes the second one (index 1). The FormArray originally had controls at indices 0, 1, 2. After removal, what are the remaining controls’ indices?