Introduction

When creating forms in Angular, sometimes you want to have an input that is not a standard text input, select, or checkbox. By implementing the ControlValueAccessor interface and registering the component as a NG_VALUE_ACCESSOR, you can integrate your custom form control seamlessly into template-driven or reactive forms just as if it were a native input!

In this article, you will transform a basic star rating input component into a ControlValueAccessor.

Prerequisites

To complete this tutorial, you will need:

Node.js installed locally, which you can do by following How to Install Node.js and Create a Local Development Environment.
Some familiarity with setting up an Angular project and using Angular components may be beneficial.

This tutorial was verified with Node v16.4.2, npm v7.18.1, angular v12.1.1.

Step 1 — Setting Up the Project

First, create a new RatingInputComponent.
This can be accomplished with @angular/cli:

ng generate component rating-input --inline-template --inline-style --skip-tests --flat --prefix

This will add the new component to the app declarations and produce a rating-input.component.ts file:
src/app/rating-input.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'rating-input',
  template: `
    <p>
      rating-input works!
    </p>
  `,
  styles: [
  ]
})
export class RatingInputComponent implements OnInit {

  constructor() { }

  ngOnInit(): void {
  }

}

Add the template, styles, and logic:
src/app/rating-input.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'rating-input',
  template: `
    <span
      <^>*ngFor="let starred of stars; let i = index"
      (click)="rate(i + (starred ? (value > i + 1 ? 1 : 0) : 1))"<^>
    >
      <ng-container *ngIf="starred; else noStar">⭐</ng-container>
      <ng-template #noStar>·</ng-template>
    </span>
  `,
  styles: [`
    span {
      display: inline-block;
      width: 25px;
      line-height: 25px;
      text-align: center;
      cursor: pointer;
    }
  `]
})
export class RatingInputComponent {
  stars: boolean[] = Array(5).fill(false);

  get value(): number {
    return this.stars.reduce((total, starred) => {
      return total + (starred ? 1 : 0);
    }, 0);
  }

  rate(rating: number) {
    this.stars = this.stars.map((_, i) => rating > i);
  }
}

We can get the value of the component (`` to 5) and set the value of the component by calling the rate function or clicking the number of stars desired.
You can add the component to the application:
src/app/app.component.html

<rating-input></rating-input>

And run the application:

ng serve

And interact with it in a web browser.
This is great, but we can’t just add this input to a form and expect everything to work just yet. We need to make it a ControlValueAccessor.

Step 2— Creating a Custom Form Control

In order to make the RatingInputComponent behave as though it were a native input (and thus, a true custom form control), we need to tell Angular how to do a few things:

Write a value to the input – writeValue
Register a function to tell Angular when the value of the input changes – registerOnChange
Register a function to tell Angular when the input has been touched – registerOnTouched
Disable the input – setDisabledState

These four things make up the ControlValueAccessor interface, the bridge between a form control and a native element or custom input component. Once our component implements that interface, we need to tell Angular about it by providing it as a NG_VALUE_ACCESSOR so that it can be used.
Revisit rating-input.component.ts in your code editor and make the following changes:
src/app/rating-input.component.ts

import { Component, forwardRef, HostBinding, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'rating-input',
  template: `
    <span
      *ngFor="let starred of stars; let i = index"
      (click)="onTouched(); rate(i + (starred ? (value > i + 1 ? 1 : 0) : 1))"
    >
      <ng-container *ngIf="starred; else noStar">⭐</ng-container>
      <ng-template #noStar>·</ng-template>
    </span>
  `,
  styles: [`
    span {
      display: inline-block;
      width: 25px;
      line-height: 25px;
      text-align: center;
      cursor: pointer;
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => RatingInputComponent),
      multi: true
    }
  ]
})
export class RatingInputComponent implements ControlValueAccessor {
  stars: boolean[] = Array(5).fill(false);

  // Allow the input to be disabled, and when it is make it somewhat transparent.
  @Input() disabled = false;
  @HostBinding('style.opacity')
  get opacity() {
    return this.disabled ? 0.25 : 1;
  }

  // Function to call when the rating changes.
  onChange = (rating: number) => {};

  // Function to call when the input is touched (when a star is clicked).
  onTouched = () => {};

  get value(): number {
    return this.stars.reduce((total, starred) => {
      return total + (starred ? 1 : 0);
    }, 0);
  }

  rate(rating: number) {
    if (!this.disabled) {
      this.writeValue(rating);
    }
  }

  // Allows Angular to update the model (rating).
  // Update the model and changes needed for the view here.
  writeValue(rating: number): void {
    this.stars = this.stars.map((_, i) => rating > i);
    this.onChange(this.value);
  }

  // Allows Angular to register a function to call when the model (rating) changes.
  // Save the function as a property to call later here.
  registerOnChange(fn: (rating: number) => void): void {
    this.onChange = fn;
  }

  // Allows Angular to register a function to call when the input has been touched.
  // Save the function as a property to call later here.
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  // Allows Angular to disable the input.
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
}

This code will allow the input to be disabled, and when it is make it somewhat transparent.
Run the application:

ng serve

And interact with it in a web browser.
You can also disable the input controls:
src/app/app.component.html

<rating-input [disabled]="true"></rating-input>

We can now say that our RatingInputComponent is a custom form component! It will work just like any other native input (Angular provides the ControlValueAccessors for those!) in template-driven or reactive forms.

Conclusion

In this article, you transformed a basic star rating input component into a ControlValueAccessor.
You’ll notice that now:

ngModel just “works”.
We can add custom validation.
Control state and validity become available with ngModel, such as ng-dirty and ng-touched classes.

If you’d like to learn more about Angular, check out our Angular topic page for exercises and programming projects.