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: