Creating Custom Form Input Components with ControlValueAccessor
A while ago I wrote a tutorial on building a custom input component for an Ionic application. It used an HTML5 <canvas>
and a gesture listener to create a rating/satisfaction input component that looks like this:
It was just a bit of a silly/fun component to serve as an example for learning about custom components, using gesture listeners, and interacting with a canvas in an Ionic application. Something like this could legitimately be used as part of a form, or some other custom component that would act as a non-typical style input for a form. However, there is a problem with using custom components in forms.
The default inputs we are familiar with (like text
, textarea
, select
, and so on), and even Ionic's own input components, are compatible with Angular's form controls. This means that we can attach ngModel
:
<ion-input type="text" [(ngModel)]="myValue"></ion-input>
or we can set up form controls:
<ion-input type="text" formControlName="myControl"></ion-input>
To utilise Angular's powerful form controls. If you are unfamiliar with using FormBuilder
, FormGroup
, and Validators
in Angular forms, I would recommend reading Advanced Forms & Validation in Ionic. In short, using the tools that Angular provides us to build and interact with forms makes our lives a lot easier.
If we want to integrate a custom component that we have built as part of a form, we would want to do something like this:
<app-smile-input formControlName="rating"></smile-input>
or
<app-smile-input [(ngModel)]="rating"></smile-input>
But that isn't going to work. Angular allows you to update form inputs, to receive notifications when they change, to set a disabled state, to grab the current value, and more. Since this is a custom component we have built, how is Angular supposed to know where to grab the value from? or where to update the value? or when the value changes?
Outline
Introducing ControlValueAccessor
Angular simply will not know how your custom component works, and so won't be able to figure out those things I just mentioned. This is where ControlValueAccessor comes in. The documentation (and even the name) looks somewhat intimidating and confusing, but the idea is pretty straight-forward.
According to the documentation a ControlValueAccessor
acts as a bridge between the Angular Forms API and a native element in the DOM. We can use ControlValueAccessor
in our custom components to let the Angular forms API know how to update the value in our component, when the input has been updated, and so on.
To do that, we will need to implement a few functions. The interface that needs to be implemented looks like this:
interface ControlValueAccessor {
writeValue(obj: any): void
registerOnChange(fn: any): void
registerOnTouched(fn: any): void
setDisabledState(isDisabled: boolean)?: void
}
Our custom component will need to implement each of these functions:
writeValue
registerOnChange
registerOnTouched
setDisabledState
(optional)
The writeValue
method will be used by the Angular forms API to modify the value of the component. The registerOnChange
function will allow Angular to provide our component with a function (i.e. Angular calls this function, to pass us its own function) that we can make a call to when a change occurs. The same goes for the registerOnTouched
function. The setDisabledState
method will be called by Angular to disable our input – if you want to implement this you might make some graphical change to the component to indicate that it is disabled.
Implementing this ControlValueAccessor
interface is basically a way to give Angular the functionality it needs to treat your custom component like any other kind of standard form input.
Let's start working on implementing this.
Implementing ControlValueAccessor
First, let's take a look at a very basic custom input component that does not yet implement ControlValueAccessor
:
import { Component } from '@angular/core';
@Component({
selector: 'app-random-input',
templateUrl: './random-input.component.html',
styleUrls: ['./random-input.component.scss'],
})
export class RandomInputComponent {
public randomNumber: number;
constructor() {}
setRandomNumber() {
this.randomNumber = Math.random();
}
}
I've kept this component intentionally simple so that it is easier to demonstrate the ControlValueAccessor
interface. This custom input component will allow the user to click a button that calls setRandomNumber
to set randomNumber
to a random number. The template for this component just looks like this:
<p>{{ randomNumber }}</p>
<ion-button color="light" (click)="setRandomNumber()">Set Number</ion-button>
We know that the value we are interested in is randomNumber
and that if you wanted to update the value of the input you would just change randomNumber
– but Angular does not know that. Now let's take a look at the component with ControlValueAccessor
implemented:
import { Component } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-random-input',
templateUrl: './random-input.component.html',
styleUrls: ['./random-input.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: RandomInputComponent,
multi: true,
},
],
})
export class RandomInputComponent implements ControlValueAccessor {
public randomNumber: number;
disabled = false;
onChange = (randomNumber: number) => {};
onTouch = () => {};
setRandomNumber(): void {
this.writeValue(Math.random());
this.onTouch();
}
// Allow Angular to set the value on the component
writeValue(value: number): void {
this.onChange(value);
this.randomNumber = value;
}
// Save a reference to the change function passed to us by
// the Angular form control
registerOnChange(fn: (randomNumber: number) => void): void {
this.onChange = fn;
}
// Save a reference to the touched function passed to us by
// the Angular form control
registerOnTouched(fn: () => void): void {
this.onTouch = fn;
}
// Allow the Angular form control to disable this input
setDisabledState(disabled: boolean): void {
this.disabled = disabled;
}
}
First, we've added the NG_VALUE_ACCESSOR
provider to the decorator for our component – this is necessary for implementing the ControlValueAccessor
interface, and we have indicated that the RandomInputComponent
"implements" ControlValueAccessor
. We have also added those four functions we were talking about.
Notice that registerOnChange
is passed a function, and then we set that function on this.onChange
. Angular will call registerOnChange
in our component and it will pass the component a function, we then save a reference to that function so that we can call it later (when a change occurs). We are doing the exact same thing for registerOnTouched
.
The setDisabledState
will simply be called when the input is being disabled – we are just toggling a flag to represent the disabled state, and we could then use that to impact the component in some way.
The most important part is that we are now modifying the randomNumber
class member by passing a value to the writeValue
method. This will allow Angular to call this method to set its own value if needed. Since we have made this change, we also modify our setRandomNumber
function that is being called from our template to use the writeValue
method as a means for changing the value too.
We will now be able to use this custom component with the Angular forms API in the same way that we would use any other default or Ionic input component.
Summary
You could have any number of crazy things happening in your component – maybe you are even communicating with Bluetooth, taking photos, making calls to a server – but in the end, the component is going to be responsible for supplying a single value (or maybe it is an array of values or an object). As long as we implement this interface, Angular will be able to understand how to interact with your component, and you will be able to use all of the benefits that the Angular forms API has to offer.