Angular Material – Custom Reactive Form Control with Error State

angularangular-material2

I am following this tutorial from the Angular Material website about creating a custom form control. There are no examples in the tutorial regarding how to respect form control errors when there are validation errors.

custom-text.component.html

<mat-form-field class="example-full-width">
    <mat-select [placeholder]="placeholder" #select [formControl]="control">
        <mat-option *ngFor="let food of foods" [value]="food">
            {{food}}
        </mat-option>
    </mat-select>
    <mat-error>This is required.</mat-error>
</mat-form-field>

custom-text.component.ts

import { Component, ViewChild, HostBinding, Input, ChangeDetectionStrategy, Optional, Self, DoCheck, OnInit, NgZone } from '@angular/core';
import { ControlValueAccessor, NgControl, NgForm, FormGroupDirective, FormControlDirective, FormControlName, FormControl, FormBuilder } from '@angular/forms';
import { MatFormFieldControl, MatSelect, CanUpdateErrorState, ErrorStateMatcher } from '@angular/material';

@Component({
    selector: 'custom-text',
    templateUrl: './custom-text.component.html',
    styleUrls: [
        './custom-text.component.scss'
    ],
    providers: [
        {
            provide: MatFormFieldControl,
            useExisting: CustomTextComponent
        }
    ],
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomTextComponent implements ControlValueAccessor, OnInit, DoCheck {

    @Input()
    foods: string[];

    @Input()
    get errorStateMatcher(): ErrorStateMatcher {
        return this.select.errorStateMatcher;
    }
    set errorStateMatcher(val) {
        this.select.errorStateMatcher = val;
    }

    @Input()
    get placeholder() {
        return this.select.placeholder;
    }
    set placeholder(plh) {
        this.select.placeholder = plh;
        this.stateChanges.next();
    }

    @Input()
    get value() {
        return this.select.value;
    }
    set value(val) {
        this.select.value = val;
        this.stateChanges.next();
    }

    @ViewChild('select')
    select: MatSelect;

    control: FormControl;

    constructor(
        @Optional() @Self() ngControl: NgControl,
        @Optional() private _controlName: FormControlName) {
        if (ngControl) {
            ngControl.valueAccessor = this;
        }
    }

    ngOnInit(): void {
        this.control = this._controlName.control;
    }

    ngDoCheck(): void {
        this.select.updateErrorState();
    }

    writeValue(obj: any): void {
        this.value = obj;
    }
    registerOnChange(fn: any): void {
        this.select.registerOnChange(fn);
    }
    registerOnTouched(fn: any): void {
        this.select.registerOnTouched(fn);
    }
    setDisabledState?(isDisabled: boolean): void {
        this.select.setDisabledState(isDisabled);
    }


}

app.component.html

<div style="text-align:center">
  <form class="example-form" [formGroup]="myForm" (submit)="submitForm()">
    <custom-text [foods]="[null, 'burger', 'spaghetti', 'fries']" 
      formControlName="selectedFood" 
      [errorStateMatcher]="matcher"></custom-text>
    <button>Submit</button>
  </form>
</div>

Basically, I injected FormControlName instance into the custom control.

constructor(
    @Optional() private _controlName: FormControlName) {
....
ngOnInit(): void {
    this.control = this._controlName.control;
}

I then bind it's control property into the inner mat-select control.

<mat-select [placeholder]="placeholder" #select [formControl]="control">

I then call this.select.updateErrorState inside ngDoCheck.

Here's the link to StackBlitz:
https://stackblitz.com/edit/angular-c4ufpp

Is there is a better or more standard way of this?

Best Answer

Instead of using the Validator interface, which create the cyclic error dependency, add errorState in your custom component that checks the ngControl that was injected into the constructor, like this:

get errorState() {
  return this.ngControl.errors !== null && !!this.ngControl.touched;
}

This makes DoCheck unnecessary. In your parent component, instead of using errorMatcher, use normal Angular validators, like this:

selectedFood = new FormControl('burger', [Validators.required]);
Related Topic