Using Web Components in Angular Forms
Web components offer the benefit of using components that work anywhere, in any front end framework or technology. Most JavaScript frameworks like Angular and Vue have first-class support for web components and custom elements. Even though we can get framework level support, sometimes we want deeper integration with the high-level APIs the framework of our choice can give us. In this post, we will see how we can leverage Angular Directives to level up our web components to work seamlessly with Angular Forms. We can get the best of both worlds, Web Component reusability, and framework level integration.
Web Components in Angular
We could use a Web Component in an Angular form without issue, but because a Web Component is not an Angular form component it does not have the helpful APIs Angular Forms provides such as custom validation and form state management.
Using web components in Angular overall is quite straightforward. Web components work with the same template API we are familiar with in our Angular components. The first step is that we need to tell Angular we are using custom element tags in our application.
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import './wc-counter'; // our Web Component we will be using in our application
@NgModule({
imports: [BrowserModule, ReactiveFormsModule],
declarations: [AppComponent, CounterDirective],
bootstrap: [AppComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}
By adding CUSTOM_ELEMENTS_SCHEMA
to the AppModule Angular will not throw errors on the custom tag elements, it cannot match to the registered Angular components. Now we can import the web components we want to use. In this example, we will import a simple counter component that we created in our previous post Introduction to Web Components.
The counter component is a simple custom input that tracks a counter value entered by the user. You can see the full implementation in the demo below or read our previous tutorial.
To use the Web Component, we use the same template syntax we typically use in Angular Components.
import { Component } from '@angular/core';
@Component({
selector: 'my-app',
templateUrl: `
<h2>Web Component</h2>
<p>{{count}}</p>
<x-counter [value]="count" (valueChange)="count = $event.detail"></x-counter>
`
})
export class AppComponent {
count = 5;
}
Web components primarily communicate via properties and custom events, just like Angular Input
and Output
s. However if we were to use this component with an Angular form it doesn't work as well as a Custom Angular Form Control. We don't have a way to use Custom Validation or and of the other form APIs. Using Angular Directives, we can apply the Custom Form Control API to our web components.
Extending behavior with Directives
Angular Directives allow us to extend or add behavior to HTML elements, this includes custom element tags as well. Using a directive we can connect our counter web component to work with the Angular form API seamlessly. Ideally we would like an API like this:
<h2>Angular Reactive Forms Component using Web Component</h2>
<p>{{counter.value}}</p>
<p>Valid (min value 0): {{counter.valid}}</p>
<x-counter [formControl]="counter"></x-counter>
With the directive API, we can use the same custom element selector as our web component. By using the same selector, we can attach Angular specific API logic to our web component.
import {
Directive,
OnInit,
forwardRef,
HostBinding,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
ElementRef,
HostListener
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Directive({
selector: 'x-counter',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CounterDirective),
multi: true
}
]
})
export class CounterDirective implements ControlValueAccessor {
onChange: any = () => {};
onTouched: any = () => {};
private _value: number;
get value() {
return this._value;
}
set value(val) {
if (val !== this._value) {
this._value = val;
this.onChange(this._value);
this.onTouched();
}
}
writeValue(value) {
if (value) {
this.value = value;
}
}
registerOnChange(fn) {
this.onChange = fn;
}
registerOnTouched(fn) {
this.onTouched = fn;
}
}
Here we have a basic implementation of the ControlValueAccessor
. The ControlValueAccessor
defines a consistent API for custom input controls to communicate with Angular. By implementing this interface, Angular understands when the component value has changed or been updated and can reflect that back to the Angular Form.
Whenever the value is set in our directive, we use the onChange()
method to notify the form control has been updated. We also call onTouched()
so Angular can mark the form control as touched, which is essential for validation.
To set the value, we need to listen to the value change on our web component. We can set up a listener to our web component using a HostListener
decorator.
...
set value(val) {
if (val !== this._value) {
this._value = val;
this.onChange(this._value);
this.onTouched();
}
}
@HostListener('valueChange', ['$event.detail'])
listenForValueChange(value) {
this.value = value;
}
...
The HostListener
creates an event listener on our host element, which is the x-counter
element. Now on valueChange
we can get the updated value our web component emits and assign it to our value.
Lastly, we need to make sure if the form control is programmatically set via the Angular forms API that we update the underlying web component. We could use a HostBinding
but I had troubles getting this to work so instead I will use an ElementRef
.
...
set value(val) {
if (val !== this._value) {
this._value = val;
this.onChange(this._value);
this.onTouched();
this.elementRef.nativeElement.value = val;
}
}
constructor(private elementRef: ElementRef) { }
@HostListener('valueChange', ['$event.detail'])
listenForValueChange(value) {
this.value = value;
}
...
The ElementRef
in injected via dependency injection and gives us a reference to the host element. With this reference, we can get the raw DOM reference and set the value property of the web component.
Now that we have our directive hooked up we can use our web component with the native Angular Forms APIs.
import { Component } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
@Component({
selector: 'my-app',
template: `
<h2>Web Component</h2>
<p>{{count}}</p>
<x-counter [value]="count" (valueChange)="count = $event.detail"></x-counter>
<h2>Angular Reactive Forms Component using Web Component</h2>
<p>{{counter.value}}</p>
<p>Valid (min value 0): {{counter.valid}}</p>
<x-counter [formControl]="counter"></x-counter>
<h2>Angular Template Forms Component using Web Component</h2>
<p>{{count2}}</p>
<x-counter [(ngModel)]="count2"></x-counter>
`
})
export class AppComponent {
count = 1;
count2 = 3;
counter = new FormControl(2, Validators.min(0));
}
Because our directive connects our web component to the Angular Forms API we can now use Form APIs such as Reactive Form Controls, Custom Validation and ngModel.
<p>{{counter.value}}</p>
<p>Valid (min value 0): {{counter.valid}}</p>
<x-counter [formControl]="counter"></x-counter>
In our template, we can see we have full access to the Angular Form Control API. We can access the value as well as have Validator logic applied to the web component value.
Directives can be a powerful tool when combined with web components in Angular. We can get the full reusability of web components as well as the excellent developer experience of the framework level APIs. Take a look at the full working demo in the link below!