DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Building your own form fields the Angular way with control value accessors
Pierre Bouillon
Pierre Bouillon

Posted on

Building your own form fields the Angular way with control value accessors

Angular forms are powerful and, well known, can be used in a way that greatly simplify your own forms by also improving the reusability of your components.

We will see how by implementing our own form with our own custom field and see how we can retrieve its value.


Scenario

We will be creating a reclamation form.

From our page, a customer will be able to specify its invoice id which has some validation and logic such as:

  • A prefix since all of our invoice ids start with INV-
  • A length our invoice id is at most 8 chars long

Creating our initial form

Let's write our first version only with reactive forms.

We will need a FormGroup that is wrapping our invoiceId field:

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  form: FormGroup<{ invoiceId: FormControl<string | null> }>;

  constructor(fb: FormBuilder) {
    this.form = fb.group({
      invoiceId: ['', [Validators.pattern(/^INV-.*/), Validators.maxLength(8)]],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

and our associated HTML:

<h1>Invoice reclamation</h1>

<form [formGroup]="form">
  <label for="invoice-id">Invoice ID: </label>
  <input
    type="text"
    formControlName="invoiceId"
    id="invoice-id"
    placeholder="INV-xxxx"
  />

  <button type="submit" [disabled]="!form.valid">Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

If you want to see what's going on internally, you may want to add this bit of code at the end of your template:

<hr />
<p>Value:</p>
<pre>{{ form.value | json }}</pre>
<p>Errors:</p>
<pre>{{ form.controls.invoiceId.errors | json }}</pre>

So far so good:

Result

Enhancing the field

We might want to help the user filling this form

For example, if we could specify ourselves the prefix it could be great. Even better, we could show him the subject of his invoice when he types it to offer him some context.

Let's modify our code a bit to achieve this:

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  form: FormGroup<{ invoiceId: FormControl<string | null> }>;

  constructor(fb: FormBuilder) {
    this.form = fb.group({
      invoiceId: ['', Validators.maxLength(4)],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
<h1>Invoice reclamation</h1>

<form [formGroup]="form">
  <label for="invoice-id">Invoice ID: </label>
  <span style="font-family: 'Courier New', Courier, monospace">INV-</span>
  <input
    type="text"
    formControlName="invoiceId"
    id="invoice-id"
    placeholder="xxxx"
  />

  <p *ngIf="form.controls.invoiceId.value && form.controls.invoiceId.valid">INVOICE nΒ°...</p>

  <button type="submit" [disabled]="!form.valid">Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

That's a bit better for the user:

with enhanced behavior

However for us, not so much

By doing this we:

  • Introduced more complexity in our form even tho this behavior is only related to a specific field
  • We changed the expected value of our invoiceId field: if somebody that is dealing with it elsewhere is used to see it starts with INV-, he probably won't get at first glance why we aren't enforcing it in our validators

Fortunately, Angular offers a way for developers to write their own form fields and encapsulate them, let's do that!

The Control Value Accessor

Introduction

The ControlValueAccessor interface is a very handy interface that helps us writing our form fields

It exposes four methods (including with an optional one):

  • writeValue(obj: any): void That is called whenever a new value is provided to our field from the forms API
  • registerOnChange(fn: any): void Provides the function that we will have to call when we will be making any change to our field
  • registerOnTouched(fn: any): void Same as the previous one but for the touched behavior
  • setDisabledState(isDisabled: boolean)?: void Called whenever the forms API informs our field that the disabled state has changed so that we can apply it to our field

Applied to our field

Creating a new component

In a new InvoiceIdFormField component that will be holding our field, let's move our logic there.

We will just replace the whole form by a simple FormControl since we will only be manipulating the invoice id here:

@Component({
  selector: 'app-invoice-id-form-field',
  templateUrl: './invoice-id-form-field.component.html',
})
export class InvoiceIdFormFieldComponent {
  readonly invoiceIdControl: FormControl<string | null>;

  constructor() {
    this.invoiceIdControl = new FormControl<string | null>(
      null,
      Validators.maxLength(4)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
<label for="invoice-id">Invoice ID: </label>
<span style="font-family: 'Courier New', Courier, monospace">INV-</span>
<input
  type="text"
  [formControl]="invoiceIdControl"
  id="invoice-id"
  placeholder="xxxx"
/>

<p *ngIf="invoiceIdControl.value && invoiceIdControl.valid">INVOICE nΒ°...</p>
Enter fullscreen mode Exit fullscreen mode

Implementing ControlValueAccessor

Now that our component is created, we can implement the interface:

@Component({
  selector: 'app-invoice-id-form-field',
  templateUrl: './invoice-id-form-field.component.html',
})
export class InvoiceIdFormFieldComponent implements ControlValueAccessor {
+ private _onChange: (invoiceId: string | null) => any = noop;
+ private _onTouched: () => any = noop;

  readonly invoiceIdControl: FormControl<string | null>;

  constructor() {
    this.invoiceIdControl = new FormControl<string | null>(
      null,
      Validators.maxLength(4)
    );
  }

+ writeValue(invoiceId: string | null): void {
+   this.invoiceIdControl.setValue(invoiceId);
+ }

+ registerOnChange(fn: any): void {
+   this._onChange = fn;
+ }

+ registerOnTouched(fn: any): void {
+   this._onTouched = fn;
+ }

+ setDisabledState?(isDisabled: boolean): void {
+   isDisabled
+     ? this.invoiceIdControl.disable()
+     : this.invoiceIdControl.enable();
+ }
}
Enter fullscreen mode Exit fullscreen mode

Since everything is ready for Angular to accept our component as a field, we now have to propagate the value when it is set:

export class InvoiceIdFormFieldComponent implements OnDestroy, ControlValueAccessor {  
  // ...
  private readonly _componentDestroyed$ = new Subject();

  constructor() {
    this.invoiceIdControl = new FormControl<string | null>(
      null,
      Validators.maxLength(4)
    );

    this.invoiceIdControl.valueChanges.pipe(
      map(invoiceId => (invoiceId ? 'INV-' : '') + invoiceId),
      takeUntil(this._componentDestroyed$),
    ).subscribe(invoiceId => {
      this._onChange(invoiceId);
      this._onTouched();
    });
  }

  ngOnDestroy(): void {
    this._componentDestroyed$.next('');
    this._componentDestroyed$.complete();
  }

  // ...
Enter fullscreen mode Exit fullscreen mode

Since we are manipulating our value internally, we can now prepend the INV- prefix ourselves

Last but not least, we now have to indicate to Angular's form API how to use our component as a field:

@Component({
  selector: 'app-invoice-id-form-field',
  templateUrl: './invoice-id-form-field.component.html',
+ providers: [
+   {
+     provide: NG_VALUE_ACCESSOR,
+     useExisting: forwardRef(() => InvoiceIdFormFieldComponent),
+     multi: true
+   }
+ ]
})
Enter fullscreen mode Exit fullscreen mode

Using our form field

Great! Now that this field and its logic is encapsulated, we can use it from our main form.

Let's start by simplifying the validators:

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent {
  form: FormGroup<{ invoiceId: FormControl<string | null> }>;

  constructor(fb: FormBuilder) {
    this.form = fb.group({ invoiceId: '' });
  }
}
Enter fullscreen mode Exit fullscreen mode

And then we can just replace our previous template simply by our field:

<h1>Invoice reclamation</h1>

<form [formGroup]="form">
  <app-invoice-id-form-field
    formControlName="invoiceId"
  ></app-invoice-id-form-field>

  <button type="submit" [disabled]="!form.valid">Send</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Let's test it:

With CVA

We can see that our component is recognized by Angular as a form field and can be bound to our form value.

Even if we are using a FormGroup there, you can also use [(ngModel)] if you only have a single value, no need to change anything

Propagating the errors

This is great, however, you might have noticed that breaking a validator of our invoiceIdControl does not propagate the error to the englobing form.

This is surprisingly harder that what we achieved so far and we will need to change the way our control value accessor is defined to do so.

For this to work, we will need to retrieve the NgControl Angular is using for this component by capturing it using the dependency injection and reroute its accessor to the component itself:

  constructor(
+   @Self()
+   private readonly control: NgControl
  ) {
+   this.control.valueAccessor = this;

    this.invoiceIdControl = new FormControl<string | null>(
      null,
-     Validators.maxLength(4)
+     Validators.maxLength(8)
    );

    this.invoiceIdControl.valueChanges
      .pipe(
        map((invoiceId) => (invoiceId ? 'INV-' : '') + invoiceId),
        takeUntil(this._componentDestroyed$)
      )
      .subscribe((invoiceId) => {
        this._onChange(invoiceId);
        this._onTouched();
      });
  }
Enter fullscreen mode Exit fullscreen mode

However, by doing so, the component will be injecting himself in its constructor, resulting in a circular dependency. In order to break it, we will have to remove it from the NG_VALUE_ACCESSOR provider:

@Component({
  selector: 'app-invoice-id-form-field',
  templateUrl: './invoice-id-form-field.component.html',
- providers: [
-   {
-     provide: NG_VALUE_ACCESSOR,
-     useExisting: forwardRef(() => InvoiceIdFormFieldComponent),
-     multi: true,
-   },
- ],
})
Enter fullscreen mode Exit fullscreen mode

Finally, we will need to assign the validators of the captured control to the ones used by the component internally:

  // ...

  ngOnInit(): void { this.control.control?.setValidators([this.invoiceIdControl.validator!]);
    this.control.control?.updateValueAndValidity();
  }

  // ...
Enter fullscreen mode Exit fullscreen mode

And voilΓ , Angular is now able to interact with the control to retrieve its value and its errors:

Errors propagated

Take aways

In this post we saw:

  • How to extract the logic and the template bound to a specific field out of a form
  • How to make a component act like a form field for the Angular form API
  • How to propagate errors from our custom form field to the main form

Final words

Extracting custom form fields can really help you to keep your codebase clean and straightforward. By doing so, you will end up with clearer forms that does not deal with the extra complexity of every fields, and components that are clearly dealing only with what they are supposed to.

Our example is pretty simple but this can also result in a better user experience if you are designing your field adequately. For example, for this invoice, we could have added a link to an external service when identifying an invoice, a PDF preview of it or extra validation to help the user using this field: using a dedicated component helps you to unleash the full power of Angular inside it.

However, keep in mind that this is still a form field and you should be able to establish boundaries: making everything a form field might be as painful as not having any since you will deal with a lot of new layers.


Photo by Romain Dancre on Unsplash

Top comments (0)

async await

Visualizing Promises and Async/Await 🀯

☝️ Check out this all-time classic DEV post