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.

form-page.component.ts
tsx
@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-page.component.html
html
<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:

{ControlValueAccessor} from '@angular/forms'
ts
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.

custom-input.component.ts
ts
@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.

custom-input.component.ts
ts
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 that ngModel simplifies having to capture the value from the event.

custom-input.component.ts
ts
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();(
  }

}
custom-input.component.html
html
<input
  [attr.disabled]="isDisabled ? true : null"
  [id]="id"
  [name]="name"
  [ngModel]="value"
  (ngModelChange)="handleChange($event)"
  (blur)="handleBlur()"
/>