this talk concerns some advanced topics in relation to Angular forms, such as custom form controls, nested forms, and form projection: important subjects which, by and large, aren't covered particularly well in the Angular docs themselves. although the talk was given a few years ago, it is still relevant, as the API has not changed substantially since
unfortunately, the code shown in the demos was never released, as far as I can ascertain. I will discuss my efforts to recreate it, and also clarify a few things that I found confusing
I'm not going to discuss the first nine minutes of the talk, since it comprises an Angular forms refresher and some updates
custom form controls
a custom form control is a directive that implements the ControlValueAccessor
interface, allowing it to integrate with Angular's Form API. it can be used in an Angular form, just as any native input element can be. it works interchangeably with both reactive and template-driven form modules
what is a ControlValueAccesssor?
an interface that allows the form API to connect with DOM elements. it is responsible for updating the form model in response to some DOM event (e.g., when the user types text into an input field) and for updating DOM elements when the form model changes
why would you want to use them?
for the same reason you would create any custom component:
- to break up a template into smaller pieces
- to enable encapsulation
- to facilitate code reuse.
some specific examples are:
- non-native form control elements
- custom styling / functionality
- control wrapped with related elements
- parser / formatter directive
implementing the ControlValueAccessor interface
the ControlValueAccessor
interface comprises 3 required methods and 1 optional:
writeValue(value: any) {}
registerOnChange(fn: (value: any) => void) {}
registerOnTouched(fn: () => void) {}
setDisabledState(isDisabled: boolean) {}
these can be grouped into two read methods and two write methods. the write methods are writeValue
and setDisabledState
. they take the current state of the form model and write it into the DOM element. the read methods are registerOnChange
and registerOnTouched
. they allow listeners to be registered that are called when an event occurs within the DOM.
implementing the first of these two methods presented me with some problems, which I will discuss. the other two did not give me any difficulty
writeValue()
the writeValue()
method is called by the Forms API to set values into our component within the DOM. to do this, we need a reference to our input field. this can be retrieved from our template using a ViewChild query
@ViewChild("input") input: ElementRef;
we use this reference to set the value of the input element:
writeValue(val: any) {
if (this.input) {
this.input.nativeElement.value = val;
}
}
this differs from Kara's example in that an extra check is needed to make sure that the input
property exists before attempting to use it. this check is needed when the component is used in a reactive form, but not in a template-driven form. apparently, the Angular Reactive Forms API calls writeValue()
before input
is set
registerOnChange()
the registerOnChange()
method is called by the forms API to pass a callback to our code, which we must then call whenever there is a change within our component
// in component
registerOnChange(fn: (value: any) => void) {
this.onChange = fn;
}
// in template
<input type="text" (input)="onChange($event.target.value)"/>
depending on the environment, the above code can cause the following error:
Property 'value' does not exist on type 'EventTarget'.
this is a Typescript problem. Typescript for some reason can't determine the correct type of $event
.
this is configurable using the strictDOMEventTypes
option. the docs say this about it:
Whether $event will have the correct type for event bindings to DOM events. If disabled, it will be
any
one solution, therefore, is to set this property to false
as the excerpt from the documentation above suggests, another solution is to cast $event
to type any
:
<input (input)="onChange($any($event).target.value)"/>
you could also pass $event
as the argument to the onChange()
method and let the component handle casting,
but this is regarded as bad practise
the solution I settled on was to use a template variable and obtain the value of the input from that:
<input #thisInput (input)="onChange(thisInput.value)" />
the thisInput
template variable refers to the <input/>
element and is given the correct type by Angular
registering with the local injector
having implemented a ControlValueAccessor
, we need to let Angular's Form API know about it. we do this by registering it with the local injector using the NG_VALUE_ACCESSOR
token
// in RequiredText
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: RequiredText
}
]
NG_VALUE_ACCESSOR
is a dependency injection token representing classes that implement the ControlValueAccessor
interface.
the multi
property means that we can register multiple providers with this token.
useExisting
means that we use the existing instance of the RequiredText
component that the injector has already created when it encountered the required-text
directive. when a form directive, like ngModel
is placed on the required-text
component, it will inject all the control value accessors configured on the current injector, and then it will work out which one it wants to use, prioritising custom control value accessors, (which ours is)
validation
validation is achieved by implementing the Validator interface and registering the component using the NG_VALIDATORS
token in the local injector:
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: RequiredTextComponent,
},
{
provide: NG_VALIDATORS,
multi: true,
useExisting: RequiredTextComponent,
}
],
error messages
in order to show error messages within the component itself, we need to get a reference to the component's form control. one approach to doing this is to provide it as an input to our component. in a reactive form:
<required-text formControlName="three" [control]="form.get('three')"></required-text>
clearly, though, it would be nicer to not have to do this: it's extra code that we have to write and extra "noise" when reading through the code
better is to use dependency injection and inject the form control into our component through its constructor function
constructor(@Self() public controlDir: NgControl) {}
this works because when we add a form directive to an element the underlying object is registered with the local injector
we specify NgControl
as the provider, as it is the super-type of all form directives that we might have set on our form element - such as NgModel
and FormControlName
- so we can use our component with all of these
the @Self
decorator is necessary here so that we don't look beyond the local element injector for the NgControl instance
however, now that we are injecting NgControl
, we can have a circular dependency because NgControl
, (or rather the instances of it, e.g. NgModel
), is injecting both NG_VALUE_ACCESSOR
and NG_VALIDATOR
, which both contain the RequiredText
instance; in other words, NgModel
is injecting RequiredText
, and RequiredText
is injecting NgModel
- a circular dependency. we thus need to not provide these in our component.
instead, we have to manually wire our component up with the Angular Forms API and add validators to it
constructor(@Self() public controlDir: NgControl) {
controlDir.valueAccessor = controlDir;
}
similarily, we can configure the validators within ngOnInit
ngOnInit() {
const control = this.controlDir.control;
control.setValidators(Validators.required);
control.updateValueAndValidity();
}
we can also remove the validate
method, if we've got it, as we're no longer doing validation that way.
the above code could actually go in the constructor, but it's considered best practice to keep as much logic out of there as possible
now that we have a reference to the formControl, we can now set up the error messages within our component. there's nothing too complicated about this:
we just query the form control for properties such as valid
and pristine
and render an error message accordingly:
<div *ngIf="controlDir && !controlDir.control.pristine &&!controlDir.control.valid" class="error">There was an error</div>
nested forms
a nested form is a component that comprises part of a form. a common example is an address component that contains separate fields for street, city, postcode etc. the motivation for having a nested form is similar to that of a custom form component: to group related behaviour and for ease of re-use
there are two kinds of nested form components that Kara talks about:
- composite ControlValueAccessor component: a component which implements the
ControlValueAccessor
interface but contains multiple form controls instead of just one - sub-form component: a Component which does not implement the
ControlValueAccessor
interface and contains a form fragment
composite ControlValueAccessor component (CCC)
implementing a composite ControlValueAccessor
is largely the same as implementing the kind that we've already seen
this seems to me to be the best way to do nested forms: it gives the greatest amount of flexibility and reusability. the component works in the same way as a native input element, making it easier to compose complex forms out of them
whilst validation and error messages can be self-contained within the component, and we don't have to pass in the form control, it's important to implement the validate()
method so that the component's valid status stays in sync with the containing form
sub form component
it seems like not having to implement the ControlValueAccessor
interface would make sub-form components easier to build, but, in fact, it's the opposite. for reasons we'll go into, it's probably better not to choose this over a composite ControlValueAccessor, but studying them is nonetheless instructive
complicating things is the fact that there's a couple of mistakes in the slides. the first of these is at around 32:50 where the component is called 'RequiredText' instead of 'AddressComponent'. the released slides, fortunately, correct this error
let's now have a closer look at what we mean by a sub-form component
supposing we have a form which contains a firstName
field, and a group of fields that comprise the address. we might organise this as follows:
<form #form="ngForm">
<label for="name">name</label>
<input name="firstName" ngModel id="name" required />
<div ngModelGroup="address">
<input ngModel name="city"/>
<input ngModel name="postcode"/>
</div>
</form>
the address fields are grouped using the ngModelGroup
directive.
now supposing we want the address section to be its own component. we might create an Address component with a template that looks like this:
<div ngModelGroup="address">
<input ngModel name="city"/>
<input ngModel name="postcode"/>
</div>
we might just expect this to work since we haven't changed the actual HTML in anyway, but instead we get this error:
Error: NodeInjector: NOT_FOUND [ControlContainer]
about a ControlContainer, Angular docs says this:
A base class for directives that contain multiple registered instances of NgControl.
if we look at the list of subclasses we see NgForm
amongst them. from this we can deduce that the ControlContainer
is in fact our form.
what is happening is that our component is looking for NgForm
in the injector but can't find it.
the reason is that NgForm
is configured that it will only inject an instance of ControlContainer if it exists in an injector that is not higher in the hierarchy of element injectors than that for the template of the Host component.
the way to fix this is to provide NgForm
within the viewProviders
array of AddressComponent
:
viewProviders: [
{ provide: ControlContainer, useExisting: NgForm}
],
but what if the ControlContainer
isn't NgForm
? after all, as we saw earlier, ControlContainer
has many sub-classes. to explore this possibility, let's change our form so that the address component has as its parent an ngModelGroup
instead of an ngModelForm
:
<form #form="ngForm">
<label for="name">name</label>
<input name="firstName" ngModel id="name" required />
<div ngModelGroup="home">
<input name="telephone" ngModel/>
<my-address></my-address>
</div>
</form>
we will find that our form data now looks like this:
{
"firstName": "",
"home": {
"telephone": ""
},
"address": {
"city": "",
"postcode": ""
}
}
contrary to expectations, address
is not nested within home
.
what has happened is that the Address Component is still configured to see NgForm
as its ControlContainer
and cannot see the NgModelGroup
at all
if we change viewProviders
so that ControlContainer
looks for an NgModelGroup
instead:
viewProviders: [
{ provide: ControlContainer, useExisting: NgModelGroup}
],
then the form data looks like what we would like it to be:
"firstName": "",
"home": {
"telephone": "",
"address": {
"city": "",
"postcode": ""
}
}
}
this illustrates an important constraint of Sub Form Components: you have to be careful about how you nest them as you may experience unexpected behaviour. this same constraint is why sub-forms are not interchangeable with reactive and template-driven forms
just to prove this, let's see how you would implement a sub-form using reactive forms. when we create this template within AddressComponent
:
<div formGroupName="address">
<input formControlName="street"/>
<input formControlName="city"/>
</div>
we get the following error:
Error: Cannot read property 'getFormGroup' of null
as before, we register the ControlContainer
in the viewProviders
array, this time assigning it to be a FormGroupDirective
instead of NgModelGroup
:
viewProviders: [
{ provide: ControlContainer, useExisting: FormGroupDirective}
],
now we get a different error:
ERROR
Error: Cannot find control with name: 'address'
this is because the address
FormGroup
does not exist. we need to create it within AddressComponent
. we do this by adding the FormGroup to the parent form:
parent: FormGroupDirective;
constructor(parent: FormGroupDirective) {
//this.form = parent.form;
this.parent = parent;
}
ngOnInit() {
this.parent.form.addControl('address', new FormGroup({
street: new FormControl(),
city: new FormControl(),
}))
}
now it will work
[in the slides for this there's another mistake: this.form
within the constructor wont work because parent.form is null at this point.
there's a discussion about this here]
form projection
form projection is where you project content into a form element. for example, you have a wrapper component that looks something like this:
<form>
<ng-content></ng-content>
</form>
and used like this:
<wrapper-component>
<input />
</wrapper-component>
the <input/>
element will be projected into the form so that the final DOM looks like:
<form>
<input/>
</form>
this approach is somewhat complex to get working and Kara advises against it; nonetheless, she shows a way of making it work
in the demo, this is the content that is being projected into a form:
<div ngModelGroup="address">
<input name="street" ngModel/>
<input name="city" ngModel/>
</div>
code example is a demo that shows this.
note the error message:
NodeInjector: NOT_FOUND [ControlContainer]
this is the same error we got with sub-form components.
once again, ngModelGroup
cannot find its ControlContainer
: the NgForm
directive.
the same solution will not work here though as the situation is a little different.
for the FormStepper
component, we have a view in which the <form>
exists, and content, where we find the form elements, including the ngModelGroup directive that is searching for the ControlContainer.
the problem is that the view and the content are not able to directly contact each other; therefore, they must do so through the FormStepper component.
within the FormStepper component, we obtain the NgForm
directive using a ViewChild query.
we provide this to the component's content by adding it to the Providers
array using useFactory
. we hope that this should allow ngModelGroup
to get a reference to NgForm
through dependency injection
but this too is insufficient. there are two reasons for this. Kara gives one reason, and the other one I discovered myself. Kara's reason is that the form still can't be found by ngModelGroup
because it lives in the view, whilst ngModelGroup
is in the content, and because content is instantiated before the view, the form doesn't exist at the point when ngModelGroup
goes looking for it. the second problem is that useFactory also runs before the template view is created and so the reference it gets to it is undefined
dealing with the first problem first, the solution is to instantiate the content after the view has been created. we do this by wrapping the content in an ng-template
element (which means it's not rendered), obtaining this within our component using a ChildContent
query, then placing it into our view using a ngTemplateOutlet
directive:
<div *ngTemplateOutlet="template"></div>
this cleverly means content is processed after the view is instantiated
the second problem can be fixed by forcing the ViewChild query to run earlier. when Kara did this talk, the ViewChild would run, by default, before change detection. later this default was changed to running afterwards. the ViewChild query takes an option of static: true
to force it to run before. this was pointed out to me by StackOverflow user g0rb, and I thank him for his help
@ViewChild(NgForm, { static: true }) form: NgForm;
here is the working code
conclusion
this is by far the best resource I've managed to find on advanced Angular form techniques. some of the techniques shown are probably not ones you absolutely would need to know - or may even be described as anti-patterns - but they are useful to know in case you do come across them, and they are instructive as to some of the deeper workings of Angular
resources
-
another good resource regarding the
ControlValueAccessor
interface is this blog by Jennifer Wadella. she also does some talks on the same subject that can be found on YouTube -
Ward Bell, wrote an article about reactive forms that mentions this talk. he remarks that he's somewhat confused by the section on nested forms