Building custom Reactive Form components
A deepdive in implementing Angular's Control Value Accessor.
An intro to Reactive Forms
In Angular we've got 2 primary ways of making forms, Template Driven Forms, and Reactive Forms.
Where Template Driven Forms are mostly two-way binding onto input components, Reactive Forms allow us to create the model in our Angular components and then form components become a simple presentation of the Reactive FormGroup
instance.
Anatomy of a Reactive Form
Reactive forms are composed of instances of FormGroup
and FormControl
(there's also FormArray
but I'll save that for another blog). They have their own lifecycle events to manage both sync and async validation, and are designed to work beautifully with the Angular change detection strategies.
@Component({ selector: 'app-form-page', templateUrl: './form-page.component.html', }) export class FormPageComponent { form = new FormGroup({ firstName: new FormControl('Josephine'), lastName: new FormControl('Bloggs'), }); }
Within our HTML template, we can then access these form controls and bind them onto our form components (or custom components).
<form [formGroup]="form"> <label for="first-name">First Name</label> <input id="first-name" formControl="firstName" /> <label for="last-name">Last Name</label> <input id="last-name" formControl="lastName" /> </form>
What's Control Value Accessor?
Control Value Accessor is the fancy name for an interface with standard methods for interacting with Angular FormControl
classes, which is used with an injection token that Angular's formControlName
directive can use to gain access to your instance.
So let's dig into the interface a bit first so we can understand the different methods and what they do:
export declare interface ControlValueAccessor { writeValue(obj: any): void; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; setDisabledState?(isDisabled: boolean): void; }
writeValue(obj: any)
Unlike React form components which pass state on each render, Angular writeValue
only runs when the FormControl
value is initially set, and future changes.
As a performance update, it only runs with external changes, so you don't need to worry about your component triggering infinite recursion and a stack overflow.
registerOnChange(fn: any); / registerOnTouched(fn: any);
When your component is first connected to the FormControl
the registerOnChange
and registerOnTouched
methods will be called with a callback function in each.
This is one of those passing functions things that we love to hate so much.
You'll need to store these within your component instance so that you've got access to them when your data changes or an interaction occurs.
The callback you receive via registerOnChange
is the one that you should call whenever your data changes.
The callback you receive via registerOnTouched
is the one that you should call to indicate the user has completed their interaction - such as (blur)
for elements such as <input type="text"/>
and (change)
for elements such as <input type="checkbox"/>
setDisabledState(isDisabled: boolean)
Our final function is the`setDisabledState` function, which is an optional implementation.
This is called initially should the FormControl
be disabled, and at any time should the FormControl
become disabled/enabled.
Putting it all together
Once you've implemented the ControlValueAccessor
interface, you should be left with some code which looks something like this - give or take.
I also set a default value on the onChange
and onTouched
methods before they're overwritten just incase anything goes wrong and they get called before registerOnChange
/ registerOnTouched
have been called.
@Component({ selector: 'app-custom-input', templateUrl: './custom-input.component.html', }) export class CustomInputComponent implements ControlValueAccessor { value: any = ''; isDisabled = false; onChange: (value: any) => void = noop; onTouched: () => void = noop; registerOnChange(cb: (value: any) => void): void { this.onChange = cb; } registerOnTouched(cb: () => void): void { this.onTouched = cb; } writeValue(value: any): void { this.value = value; } setDisabledState(isDisabled: boolean): void { this.isDisabled = isDisabled } }
Providing the NG_VALUE_ACCESSOR
As we discussed earlier, we've got the ControlValueAccessor
part of the equation, and we've got the injection token which the Angular team aptly named the NG_VALUE_ACCESSOR
, whilst this may sound a little scary, it's simply an injection token.
You add a little bit of metadata to your @Component
declaration telling Angular that if it's looking for NG_VALUE_ACCESSOR
that it can use the existing instance of this component.
import {Component} from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; @Component({ selector: 'app-custom-input', templateUrl: './custom-input.component.html', providers: [ { provide: NG_VALUE_ACCESSOR, useClass: CustomInputComponent, useExisting: true, } ] }) export class CustomInputComponent implements ControlValueAccessor { ... }
And that's it!
Wrapping up
Whilst it may sound scary at first, once you've written your first few ControlValueAccessor
component's you'll find this comes second nature to you, and you can start looking at other patterns to enable amazing reuse.
See the full example below:
Note: I'm using
ngModel
internally to sync the DOM with the component instance. You can use any event to do this, I find thatngModel
simplifies having to capture the value from the event.
import {Component} from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {noop} from 'rxjs'; @Component({ selector: 'app-custom-input', templateUrl: './app-custom-input.component.html', providers: [ { provide: NG_VALUE_ACCESSOR, useClass: CustomInputComponent, useExisting: true, } ] }) export class CustomInputComponent implements ControlValueAccessor { value: any = ''; isDisabled = false; @Input() id?: string; @Input() name?: string; onChange: (value: any) => void = noop; onTouched: () => void = noop; registerOnChange(cb: (value: any) => void): void { this.onChange = cb; } registerOnTouched(cb: () => void): void { this.onTouched = cb; } writeValue(value: any): void { this.value = value; } setDisabledState(isDisabled: boolean): void { this.isDisabled = isDisabled } handleChange(value: string): void { // this helps keep your components inner state in sync this.writeValue(value); // update the FormControl this.onChange(value); } handleBlur(): void { this.onTouched();( } }
<input
[attr.disabled]="isDisabled ? true : null"
[id]="id"
[name]="name"
[ngModel]="value"
(ngModelChange)="handleChange($event)"
(blur)="handleBlur()"
/>