Tutorial

How To Use ControlValueAccessor to Create Custom Form Controls in Angular

Angular

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!

Animated gif of the Rating Input Component example selecting a different number of stars.

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

Prerequisites

To complete this tutorial, you will need:

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 (0 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:

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

Creative Commons License