Advanced reactive and signal forms

Dynamic field arrays, cross-field and async validators, custom controls with ControlValueAccessor, and how the signal-based forms model changes the picture.

FormArray and dynamic fields

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'app-invoice-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <div formArrayName="lines">
        @for (line of lines.controls; track line; let i = $index) {
          <fieldset [formGroupName]="i">
            <input formControlName="description" placeholder="Description" />
            <input formControlName="amount" type="number" placeholder="Amount" />
            @if (line.get('amount')?.invalid && line.get('amount')?.touched) {
              <p class="error">Enter a positive amount.</p>
            }
            <button type="button" (click)="removeLine(i)" [disabled]="lines.length === 1">Remove</button>
          </fieldset>
        }
      </div>

      <button type="button" (click)="addLine()">Add line</button>
      <p>Total: {{ total() | currency }}</p>
      <button type="submit" [disabled]="form.invalid">Save</button>
    </form>
  `
})
export class InvoiceFormComponent {
  private readonly fb = inject(FormBuilder);

  readonly form = this.fb.nonNullable.group({
    customer: ['', [Validators.required, Validators.minLength(2)]],
    lines: this.fb.nonNullable.array([this.newLine()], { validators: [atLeastOneLine] })
  });

  get lines() { return this.form.controls.lines; }

  private newLine() {
    return this.fb.nonNullable.group({
      description: ['', Validators.required],
      amount: [0, [Validators.required, Validators.min(0.01)]]
    });
  }

  addLine() { this.lines.push(this.newLine()); }
  removeLine(index: number) { if (this.lines.length > 1) this.lines.removeAt(index); }

  total(): number {
    return this.lines.controls.reduce((sum, group) => sum + (group.get('amount')?.value ?? 0), 0);
  }

  submit() {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }
    console.log(this.form.getRawValue());
  }
}

// A validator that reads sibling controls: it belongs on the array, not a field.
function atLeastOneLine(control: AbstractControl): ValidationErrors | null {
  const array = control as unknown as { length: number };
  return array.length > 0 ? null : { atLeastOneLine: 'Add at least one line.' };
}
💡
A child control in a FormArray has no name of its own — the index is the name. That is why formArrayName plus formGroupName="i" is needed, and why removing an item mid-list shifts every control after it.

Cross-field and async validators

import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { of, timer, Observable } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';

/** Cross-field: the error belongs on the group, not on either control. */
export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const password = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  if (!password || !confirm) return null;
  return password === confirm ? null : { passwordsMatch: 'The two passwords differ.' };
};

/** Async: checks availability on the server. Must be fast or the form feels broken. */
export function uniqueEmail(api: AccountApi): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = String(control.value ?? '');
    if (!value) return of(null);

    return timer(300).pipe(                       // debounce inside the validator
      switchMap(() => api.checkEmail(value)),
      map((taken) => (taken ? { emailTaken: true } : null)),
      catchError(() => of(null))                  // never block the form on a network error
    );
  };
}

// registration:
// this.fb.group({
//   password: ['', Validators.required],
//   confirm: ['', Validators.required]
// }, { validators: passwordsMatch })
//
// email: ['', [Validators.required, Validators.email], [uniqueEmail(api)]]
QuestionAnswerReason
Where does a cross-field error go?On the parent groupNeither child is wrong on its own
How is it read in a template?form.errors?.['passwordsMatch']Group errors are not on the control
When does an async validator run?On value change, after sync passesSync errors skip the network call
What must it never do?Throw or emit an errorA failed check must not block submission
How do you re-run it?updateValueAndValidity()Useful after a related value changes
// Marking everything touched on submit is what makes errors appear
// without the user having to visit every field.
submit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    this.form.updateValueAndValidity();   // re-run async validators before deciding
    return;
  }
  this.api.save(this.form.getRawValue()).subscribe();
}

// Status changes are worth observing for a global error summary.
this.form.statusChanges
  .pipe(takeUntilDestroyed(this.destroyRef))
  .subscribe((status) => console.log('form status', status));

Custom controls with ControlValueAccessor

import { Component, forwardRef, input, signal, booleanAttribute } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator, AbstractControl, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    @for (star of stars; track star) {
      <button type="button"
              [attr.aria-pressed]="star <= value()"
              [disabled]="disabled()"
              (click)="select(star)"
              (blur)="onTouched()">
        {{ star <= value() ? '*' : '-' }}
      </button>
    }
  `,
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRatingComponent), multi: true },
    { provide: NG_VALIDATORS, useExisting: forwardRef(() => StarRatingComponent), multi: true }
  ]
})
export class StarRatingComponent implements ControlValueAccessor, Validator {
  readonly max = input(5);
  readonly required = input(false, { transform: booleanAttribute });
  readonly value = signal(0);
  readonly disabled = signal(false);

  readonly stars = [1, 2, 3, 4, 5];

  private onChange: (value: number) => void = () => {};
  onTouched: () => void = () => {};

  // --- ControlValueAccessor ---
  writeValue(value: number | null): void { this.value.set(value ?? 0); }
  registerOnChange(fn: (value: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); }

  // --- Validator ---
  validate(control: AbstractControl): ValidationErrors | null {
    if (this.required() && !this.value()) return { required: true };
    return null;
  }

  select(star: number) {
    if (this.disabled()) return;
    this.value.set(star);
    this.onChange(star);
    this.onTouched();
  }
}
  • writeValue is called with the model value and with null when the form resets — handle null explicitly.
  • registerOnChange is how you push user changes back into the form; forgetting to call it leaves the control permanently pristine.
  • registerOnTouched is what lets the form know the user has visited the field, which is what gates error display.
  • setDisabledState must be implemented if the control is ever disabled programmatically; otherwise the disabled state is invisible.
  • Provide NG_VALUE_ACCESSOR with forwardRef because the class refers to itself before it is defined.
<!-- Any custom control now behaves like a built-in one -->
<form [formGroup]="reviewForm">
  <app-star-rating formControlName="rating" [required]="true" />
  @if (reviewForm.get('rating')?.invalid && reviewForm.get('rating')?.touched) {
    <p class="error">Please give a rating.</p>
  }
</form>

Signal forms are converging on a different model where the field tree is a set of signal nodes with their own state, validation and parsing, without the ControlValueAccessor protocol. Both models exist today; the pragmatic path is to keep reactive forms for complex existing screens and start new ones on the signal API once your Angular version supports it.

FAQ

Why is my async validator not firing?
Async validators run only after synchronous validation passes, and they are skipped while the control is PENDING. If the sync validator is failing, or if you never call updateValueAndValidity() after changing an unrelated value the validator depends on, it will not run again.
Do dynamic fields need a FormArray?
For a list of repeating groups, yes — FormArray is the only structure that keeps per-item validity. A single optional group is better modelled as a nested FormGroup that you enable or disable.

Routing and forms Testing Angular applications

Last refreshed 2026-09-18.