Skip to content

Handle multiple validation errors with Reactive Forms + Angular Material

Form controls with multiple validators often have a separate message for each possible error. The conditional rendering of these error messages is a bit cumbersome by default. Let's find a better way by building a custom directive.

Published
Jul 10, 2022
Updated
Jul 10, 2022
Reading time
4 min read

Form Controls in Angular Reactive Forms can have multiple validators. For each validator you often define a custom error message using the mat-error element of Angular Material. You need a condition to only show the active error messages and not simply all. There are different ways to achieve this.

The cumbersome way

You can use an *ngIf and the hasError() method to check if the control has the corresponding error. That's a good approach but also a bit cumbersome. For each possible error you need to repeat the form control, here form.controls.email (though you could shorten that by defining a getter for that control). When you copy this code to add another field you may forget to adjust the control.

<mat-form-field>
  <mat-label>E-mail</mat-label>
  <input type="email" matInput [formControl]="form.controls.email" />

  <!-- Shown if field is empty (and touched) -->
  <mat-error *ngIf="form.controls.email.hasError('required')">
    E-mail is required
  </mat-error>

  <!-- Shown if value is not an e-mail address -->
  <mat-error *ngIf="form.controls.email.hasError('email')">
    Enter a valid e-mail
  </mat-error>
</mat-form-field>

The better way

It would be nice if we could just specify the error name ("required" or "email") without having to repeat the form control over and over again. It should just use the form control of the current form field. Something like this:

<mat-form-field>
  <mat-label>E-mail</mat-label>
  <input type="email" matInput [formControl]="form.controls.email" />
  <mat-error *hasError="'required'">E-mail is required</mat-error>
  <mat-error *hasError="'email'">Enter a valid e-mail</mat-error>
</mat-form-field>

How it works

For our approach we just need two new directives.

A *hasError structural directive that checks if the form control has the corresponding error and either renders the mat-error element or removes it from the DOM. This directive needs access to the form control to call the hasError() method.

How can we access the form control in our structural directive? The FormControlDirective is attached to our input element which is a sibling of the mat-error element (and a sibling of our *hasError directive). Accessing sibling components/directives is not possible in Angular, so we don't have access to the form control.

We need another directive to solve this problem, which has to be a parent of both, the FormControlDirective and our *hasError directive. We can attach this parent directive to the mat-form-field component element. This new parent directive, let's call it HasErrorRootDirective, can access the FormControlDirective using the @ContentChild() decorator.

The *hasError directive can then inject our HasErrorRootDirective and access the form control.

Implementation

Let's first implement the parent directive. We use mat-form-field as a selector which will automatically attach the directive to every form field. It accesses the FormControlDirective via @ContentChild() and provides it as an observable.

@Directive({
  selector: "mat-form-field",
})
class HasErrorRootDirective {
  @ContentChild(FormControlDirective)
  set formControl(formControl: FormControlDirective) {
    this._formControl$.next(formControl);
  }

  get formControl$() {
    return this._formControl$.asObservable();
  }

  private _formControl$ = new ReplaySubject<FormControlDirective>(1);
}

The *hasError directive is a bit more complex. Let's start with the basic structure. We accept the error name (like "required") as input property, and we inject the parent HasErrorRootDirective which gives access to the form control.

@Directive({
  selector: "[hasError]",
})
class HasErrorDirective {
  @Input()
  set hasError(errorName: string) {
    this.errorName$.next(errorName);
  }

  private errorName$ = new ReplaySubject<string>(1);
  private ctrl$ = this.hasErrorRoot.formControl$;

  constructor(private hasErrorRoot: HasErrorRootDirective) {}
}

Then we need to listen for status changes to know if the form control has the error.

class HasErrorDirective {
  // ... (see above)

  // Notifies us whenever the status of the form control changes
  private status$ = this.ctrl$
    .pipe(switchMap((ctrl) => 
      (ctrl.statusChanges || EMPTY).pipe(startWith(null))
    )
  );

  // Check if the control has the error
  // and access the error value.
  private error$ = combineLatest([
    this.ctrl$, this.errorName$, this.status$
  ]).pipe(
    map(([ctrl, errorName]) => ({
      hasError: ctrl.hasError(errorName),
      value: ctrl.getError(errorName),
    })),
    distinctUntilChanged((x, y) => 
      x.hasError === y.hasError && x.value === y.value
    )
  );
}

And finally we can subscribe to our error$ observable to either render the mat-error element or remove it from the DOM.

class HasErrorDirective implements OnInit, OnDestroy {
  // ... (see above)

  private view?: EmbeddedViewRef<HasErrorContext>;
  private subscription?: Subscription;

  constructor(
    private hasErrorRoot: HasErrorRootDirective,
    private templateRef: TemplateRef<HasErrorContext>,
    private vcr: ViewContainerRef
  ) {}

  ngOnInit(): void {
    this.subscription = this.error$.subscribe((error) => {
      if (!error.hasError) {
        this.view?.destroy();
        this.view = undefined;
        return;
      }

      if (this.view) {
        this.view.context.$implicit = error.value;
        this.view.markForCheck();
        return;
      }

      this.view = this.vcr.createEmbeddedView(this.templateRef, {
        $implicit: error.value,
      });
    });
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}

We use the template context to provide the error value to the mat-error element. You can include this value in your message like this:

<mat-error *hasError="'email'; let value">
  {{ value }} is not a valid e-mail address
</mat-error>

Demo

The example app with the complete source code is available on GitHub:

github.com/rothsandro/ng-reactive-forms-errors