javascriptangularangular-reactive-formsangular-formscontrolvalueaccessor

Validation not updating in custom form controls using Angular's Control Value Accessor


I built a shared input component in Angular using Control Value Accessor interface. The input itself works but validation doesn't get updated when I change the value within the input. Here is the input component I created.

import { Component, forwardRef, Input, OnInit } from '@angular/core';
import {
  AbstractControl,
  ControlValueAccessor,
  FormsModule,
  NG_VALIDATORS,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator,
} from '@angular/forms';

@Component({
  selector: 'app-input',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './input.component.html',
  styleUrl: './input.component.scss',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputComponent),
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => InputComponent),
      multi: true,
    },
  ],
})
export class InputComponent implements ControlValueAccessor, Validator {
  @Input() value: string = '';
  @Input() type: string = 'text';
  @Input() placeholder: string = '';
  @Input() disabled: boolean = false;
  @Input() label: string = '';
  // default to a random id unless one is provided.
  @Input() id: string = '';

  isInvalid: boolean = false;
  touched: boolean = false;

  onChange = (value: string) => {};
  onTouched = () => {};
  onValidationChange = () => {};
  errorMessage: string = '';

  writeValue(value: string): void {
    this.value = value;
  }

  registerOnChange(onChange: any): void {
    this.onChange = onChange;
  }

  registerOnTouched(onTouched: any): void {
    this.onTouched = onTouched;
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  onInputChange($event: Event) {
    const input = $event.target as HTMLInputElement;
    this.value = input.value;
    this.onChange(this.value);
    this.markAsTouched();
    this.onValidationChange();
  }

  markAsTouched() {
    if (!this.touched) {
      this.onTouched();
      this.touched = true;
    }
  }

  validate(control: AbstractControl): ValidationErrors | null {
    this.errorMessage = this.getErrorMessage(control.errors);
    return control.errors;
  }

  registerOnValidatorChange(onValidationChange: () => void): void {
    this.onValidationChange = onValidationChange;
  }

  private getErrorMessage(errors: ValidationErrors | null): string {
    if (!this.touched || !errors) {
      return '';
    }
    if (errors['required']) {
      return 'This field is required';
    }
    if (errors['minLength']) {
      return `Minimum length is ${errors['minLength'].requiredLength}.`;
    }

    return '';
  }
}

When I log the control.errors within the validate method, it still shows that the required validation still has an error.

I have a sample angular project showing this issue here:

https://stackblitz.com/edit/stackblitz-starters-fy4zue?file=src%2Finput%2Finput.component.ts

Things I have tried:

Is there anything I am doing wrong, or something that I forgot to add, or is this an actual angular bug?


Solution

  • You do not need validator for this scenario, use it, when you want to embed a validation inside the custom component.

    Angular Custom Form Controls: Complete Guide

    For your scenario, all you need to do is call the function on ngOnInit and onChange and make sure you fetch the formControl object using the below method.

    constructor(
      private controlContainer: ControlContainer,
      private elementRef: ElementRef
    ) {}
    
    ngOnInit() {
      const formControlName =
        this.elementRef.nativeElement.getAttribute('formcontrolname');
      console.log(this.controlContainer, formControlName);
      this.control = this.controlContainer?.control?.get(formControlName) || null;
      console.log(this.control);
      this.errorMessage = this.getErrorMessage(this.control.errors);
    }
    

    Then onChange, we call the getErrorMessage again.

    onInputChange($event: Event) {
      const input = $event.target as HTMLInputElement;
      this.value = input.value;
      console.log('asdf', this.value);
    
      this.onChange(this.value);
      this.errorMessage = this.getErrorMessage(this.control.errors);
      this.markAsTouched();
      // this.onValidationChange();
    }
    

    Full Code:

    import {
      Component,
      ElementRef,
      forwardRef,
      Host,
      inject,
      Input,
      OnInit,
      Optional,
      SkipSelf,
    } from '@angular/core';
    import {
      AbstractControl,
      ControlContainer,
      ControlValueAccessor,
      FormsModule,
      NG_VALIDATORS,
      NG_VALUE_ACCESSOR,
      ValidationErrors,
      Validator,
      FormControl,
      NgControl,
    } from '@angular/forms';
    
    @Component({
      selector: 'app-input',
      standalone: true,
      imports: [FormsModule],
      templateUrl: './input.component.html',
      providers: [
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => InputComponent),
          multi: true,
        },
      ],
    })
    export class InputComponent implements ControlValueAccessor {
      value: string = '';
      @Input() type: string = 'text';
      @Input() placeholder: string = '';
      @Input() disabled: boolean = false;
      @Input() label: string = '';
      // default to a random id unless one is provided.
      @Input() id: string = '';
    
      isInvalid: boolean = false;
      touched: boolean = false;
    
      onChange = (value: string) => {};
      onTouched = () => {};
      errorMessage: string = '';
      control!: any;
    
      constructor(
        private controlContainer: ControlContainer,
        private elementRef: ElementRef
      ) {}
    
      ngOnInit() {
        const formControlName =
          this.elementRef.nativeElement.getAttribute('formcontrolname');
        console.log(this.controlContainer, formControlName);
        this.control = this.controlContainer?.control?.get(formControlName) || null;
        console.log(this.control);
        this.errorMessage = this.getErrorMessage(this.control.errors);
      }
    
      writeValue(value: string): void {
        console.log(value);
        this.value = value;
      }
    
      registerOnChange(onChange: any): void {
        this.onChange = onChange;
      }
    
      registerOnTouched(onTouched: any): void {
        this.onTouched = onTouched;
      }
    
      setDisabledState(isDisabled: boolean): void {
        this.disabled = isDisabled;
      }
    
      onInputChange($event: Event) {
        const input = $event.target as HTMLInputElement;
        this.value = input.value;
        console.log('asdf', this.value);
    
        this.onChange(this.value);
        this.errorMessage = this.getErrorMessage(this.control.errors);
        this.markAsTouched();
        // this.onValidationChange();
      }
    
      markAsTouched() {
        if (!this.touched) {
          this.onTouched();
          this.touched = true;
        }
      }
    
      private getErrorMessage(errors: ValidationErrors | null): string {
        if (errors?.['required']) {
          return 'This field is required';
        }
        if (errors?.['minLength']) {
          return `Minimum length is ${errors['minLength'].requiredLength}.`;
        }
        return '';
      }
    }
    

    Stackblitz Demo