23 May, 2022

Create Angular custom form controls with ControlValueAccessor

Usage of ControlValueAccessor
Photo by Markus Spiske on Unsplash.
tweet
share

Please support me with,

Table Of Contents
# # # # # # # #

Angular is a great framework that supports two-way binding. You can set the value using the @Input decorator and transmit the changed value to the parent component by emitting the valueChange emitter using the @Output decorator.

This is useful when creating simple form fields. However, this method has limitations.

First, the components like above cannot be included in FormGroup. This is because FormGroup class of Angular allows FormControl elements to be managed as a group, but FormControl cannot be bound to those components.

Second, you cannot use Validator because FormControl cannot be bound. When the value of FormControl is changed, Validator validates the value and sets the error status to FormControl.

This makes it difficult for you to manage components.

To solve this problem, you can create your own form controls using ControlValueAccessor.

ControlValueAccessor

ControlValueAccessor is the key to creating custom form controls. You can implement this in your component to allow binding of ngModel and FormControl.

/**
 * @description
 * Defines an interface that acts as a bridge between the Angular forms API and a
 * native element in the DOM.
 *
 * Implement this interface to create a custom form control directive
 * that integrates with Angular forms.
 *
 * @see DefaultValueAccessor
 *
 * @publicApi
 */
export declare interface ControlValueAccessor {
    /**
     * @description
     * Writes a new value to the element.
     *
     * This method is called by the forms API to write to the view when programmatic
     * changes from model to view are requested.
     *
     * @usageNotes
     * ### Write a value to the element
     *
     * The following example writes a value to the native DOM element.
     *
     * ```ts
     * writeValue(value: any): void {
     *   this._renderer.setProperty(this._elementRef.nativeElement, 'value', value);
     * }
     * ```
     *
     * @param obj The new value for the element
     */
    writeValue(obj: any): void;
    /**
     * @description
     * Registers a callback function that is called when the control's value
     * changes in the UI.
     *
     * This method is called by the forms API on initialization to update the form
     * model when values propagate from the view to the model.
     *
     * When implementing the `registerOnChange` method in your own value accessor,
     * save the given function so your class calls it at the appropriate time.
     *
     * @usageNotes
     * ### Store the change function
     *
     * The following example stores the provided function as an internal method.
     *
     * ```ts
     * registerOnChange(fn: (_: any) => void): void {
     *   this._onChange = fn;
     * }
     * ```
     *
     * When the value changes in the UI, call the registered
     * function to allow the forms API to update itself:
     *
     * ```ts
     * host: {
     *    '(change)': '_onChange($event.target.value)'
     * }
     * ```
     *
     * @param fn The callback function to register
     */
    registerOnChange(fn: any): void;
    /**
     * @description
     * Registers a callback function that is called by the forms API on initialization
     * to update the form model on blur.
     *
     * When implementing `registerOnTouched` in your own value accessor, save the given
     * function so your class calls it when the control should be considered
     * blurred or "touched".
     *
     * @usageNotes
     * ### Store the callback function
     *
     * The following example stores the provided function as an internal method.
     *
     * ```ts
     * registerOnTouched(fn: any): void {
     *   this._onTouched = fn;
     * }
     * ```
     *
     * On blur (or equivalent), your class should call the registered function to allow
     * the forms API to update itself:
     *
     * ```ts
     * host: {
     *    '(blur)': '_onTouched()'
     * }
     * ```
     *
     * @param fn The callback function to register
     */
    registerOnTouched(fn: any): void;
    /**
     * @description
     * Function that is called by the forms API when the control status changes to
     * or from 'DISABLED'. Depending on the status, it enables or disables the
     * appropriate DOM element.
     *
     * @usageNotes
     * The following is an example of writing the disabled property to a native DOM element:
     *
     * ```ts
     * setDisabledState(isDisabled: boolean): void {
     *   this._renderer.setProperty(this._elementRef.nativeElement, 'disabled', isDisabled);
     * }
     * ```
     *
     * @param isDisabled The disabled status to set on the element
     */
    setDisabledState?(isDisabled: boolean): void;
}

You can see 4 methods in the above interface.

writeValue()

The writeValue() method needs to set the value which is set in ngModel or FormControl to the component.

This must be implemented.

registerOnChange()

The registerOnChange() method needs to register a change listener of ngModel or FormControl in your component. When you call this change listener with value, ngModelChange or FormControl.valueChanges is triggered.

This must be implemented.

registerOnTouched()

The registerOnTouched() method registers the touched listener of ngModel or FormControl in your component. When the touched listener is called, the state of your component changes from untouched to touched.

This must be implemented.

setDisabledState()

The setDisabledState() method needs to reflect the state of disabled to your component when ngModel or FormControl is disabled or enabled.

This is optional method.

CustomFormControl

You can create your own FormControl simply by implementing ControlValueAccessor. Read more about this from here: Angular Custom Form Controls: Complete Guide.

However, I would make it an extensible Angular Directive class for general purpose. The reason for implementing Angular Directive is to use Angular's Dependency Injection.

import {Directive, HostBinding, Input, Optional, Self} from '@angular/core';
import {ControlValueAccessor, NgControl} from '@angular/forms';
import {environment} from '../../../../environments/environment';

const {
  production,
} = environment;

/**
 * The base class to create customized `FormControl`.
 */
@Directive({
  selector: '[appCustomFormControl]'
})
export class CustomFormControl<T> implements ControlValueAccessor {
  /**
   * Set component tabindex.
   */
  @Input() @HostBinding('attr.tabindex') tabindex = 0;

  /**
   * `OnChange` callback function.
   */
  protected _onChange: any;

  /**
   * `OnTouched` callback function.
   */
  protected _onTouched: any;

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

  /**
   * Should be implemented by extended class.
   * @param value - The value.
   */
  writeValue(value: T): void {
    // Show warning when `writeValue()` method is not overwritten.
    if (!production) {
      console.warn(`'writeValue()' method is not implemented.`);
    }
  }

  /**
   * Set value to `NgControl`.
   * @param value - The value.
   */
  setValue(value: T): void {
    // When setting value to `NgControl`,
    // write changed value to the Component/Directive by calling `writeValue()` method as well.
    this.writeValue(value);
    this.markAsDirty(value);
  }

  /**
   * Should be implemented by extended class.
   * @param isDisabled - The disabled state.
   */
  setDisabledState(isDisabled: boolean): void {
    // Show warning when `setDisabledState()` method is not overwritten.
    if (!production) {
      console.warn(`'setDisabledState()' method is not implemented.`);
    }
  }

  /**
   * Call `OnChange` callback function.
   * @param value - The value.
   */
  markAsDirty(value: T): void {
    if (this._onChange) {
      this._onChange(value);
    }
  }

  /**
   * Call `OnTouched` callback function.
   */
  markAsTouched(): void {
    if (this._onTouched) {
      this._onTouched();
    }
  }

  /**
   * Register `OnChange` callback function to class.
   * @param fn - The function of `OnChange`.
   */
  registerOnChange(fn: any): void {
    this._onChange = fn;
  }

  /**
   * Register `OnTouched` callback function to class.
   * @param fn - The function of `OnTouched`.
   */
  registerOnTouched(fn: any): void {
    this._onTouched = fn;
  }
}

If you've read the article linked above, you'll notice that there is no NG_VALUE_ACCESSOR provider in my code.

I used the @Self() decorator to detect the NgControl injected into this class, and set the valueAccessor of the NgControl as this class.

So the injected NgControl will use writeValue(), registerOnChange(), registerOnTouched(), and setDisabledState() of this class.

And I also used the @Optional() decorator, because NgControl may not have been injected.

The part you should pay attention to in the code above is the setValue() method. The setValue() method calls the writeValue() method to write the value back to the component. If this part is not present, you have to manually update the component value before updating the value.

Usage

Now let's create an CheckboxComponent using CustomFormControl.

@Component({
  selector: 'app-checkbox',
  templateUrl: './checkbox.component.html',
  styleUrls: ['./checkbox.component.scss']
})
export class CheckboxComponent extends CustomFormControl<boolean> {
  /**
   * Disabled state.
   */
  @HostBinding('class.disabled') disabled = false;
  
  /**
   * Set component tabindex.
   */
  @Input() @HostBinding('attr.tabindex') tabindex = 0;

  /**
   * Checked state.
   */
  checked = false;

  constructor(
    @Self() @Optional() public override ngControl: NgControl,
  ) {
    super(ngControl);
  }

  /**
   * Write checked state.
   * @param value - The value.
   */
  override writeValue(value: boolean): void {
    this.checked = value;
  }

  /**
   * Set checkbox disabled.
   * @param isDisabled - Disabled state.
   */
  override setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  /**
   * Listen host `click` event to toggle checked state.
   */
  @HostListener('click')
  onHostClick(): void {
    this.setValue(!this.checked);
  }

  /**
   * Listen host `blur` event to mark as touched.
   */
  @HostListener('blur')
  onHostBlur(): void {
    this.markAsTouched();
  }
}

The generic of CustomFormControl indicates the type of the value of FormControl. A checkbox has a checked/unchecked state, so use a boolean type.

This overrides the writeValue() method to change the value of the checked property. When the host element is clicked, the checked state is changed by calling the setValue() method. And, when a blur event occurs from the host element, the markAsTouched() method is called to change the status of NgControl to touched.

You can show/hide check icon in html template according to checked value.

Now you can use CheckboxComponent like below.

<!-- Bind with `ngModel`. -->
<app-checkbox [(ngModel)]="checked"></app-checkbox>
<!-- Use `FormControl`. -->
<app-checkbox [formControl]="control"></app-checkbox>
<!-- Use `formControlName` with `FormGroup`. -->
<form [formGroup]="group">
  <app-checkbox formControlName="checkbox"></app-checkbox>
</form>

Conclusion

Like CheckboxComponent, you can create your own FormControl using CustomFormControl if needed. I hope this article was helpful. Enjoy Angular development!

Tags
Copyright 2022, tk2rush90, All rights reserved