Angular Template-driven Forms state management
Intro
Updated 21 february 2024
Note, this article is outdated
Everything is now part of ngx-signal-forms.
In this article, we will tackle how to handle state management when using Template-driven Forms in Angular. A big difference in terms of state management between Template-driven forms and Reactive Forms in Angular is that with Template-driven Forms… We let Angular do all the work for us!! (including state management) In practice, that means:
- Angular will create form control and form group instances for us automatically.
- Angular will remove form control and form group instances for us automatically.
- We never have to manually add/remove validators.
- Our form will only contain the controls and groups that it needs.
Interested in the YouTube video? Check it here
The problem with automatic state management
Think about the next example:
We have a form that has a form group called addresses
that has a billingAddress
and an optional shippingAddress
form groups.
The user has the ability to optionally provide the shippingAddress
when a
shippingAddressIsDifferentThanBillingAddress
property that is bound to a checkbox is set to true.
This means that when the checkbox is not selected, the shippingAddress
should not be part of the form
and its validators should not be executed either.
Read This article or YouTube videos if you don’t understand how we created the form
directive and how the ViewModel works:
export type PurchaseFormModel = Partial<{
firstName: string;
lastName: string;
gender: 'female'| 'male'| 'other';
genderOther: string;
addresses: Partial<{
billingAddress: AddressFormModel;
// should not always be shown
shippingAddress: AddressFormModel;
shippingAddressDifferentFromBillingAddress: boolean;
}>
}>;
export class MyFormComponent {
// Hold the value of the unidirectional form
private readonly formValue = signal<PurchaseFormModel>({});
// Declarative ViewModel that exposes the form value
// and whether the shipping address should be shown
protected readonly viewModel = computed(() => ({
formValue: this.formValue(),
// Declarative property on when to show the shipping address or not
showShippingAddress:
this.formValue().addresses?.shippingAddressDifferentFromBillingAddress,
}));
// Expose vm as getter for shorter syntax
protected get vm() {
return this.viewModel();
}
protected setFormValue(e: PurchaseFormModel): void {
// Ensure unidirectional dataflow
this.formValue.set(e);
}
}
The HTML of this code could look like this :
<form (formValueChange)="setFormValue($event)">
...
<div ngModelGroup="addresses">
<h3>Billing address</h3>
<app-address
ngModelGroup="billingAddress"
[address]="vm.formValue.addresses?.billingAddress"
>
</app-address>
<input
type="checkbox"
[ngModel]="vm.formValue.addresses?.shippingAddressDifferentFromBillingAddress"
name="shippingAddressDifferentFromBillingAddress"
/>
<div *ngIf="vm.showShippingAddress">
<h3>Shipping address</h3>
<app-address
ngModelGroup="shippingAddress"
[address]="vm.formValue?.addresses?.shippingAddress"
>
</app-address>
</div>
</div>
</form>
Angular will turn this HTML automatically in the following dynamic form controls and form groups behind the scenes:
form = {
addresses: FormGroup = {
billingAddress: FormGroup = {
street: FormControl,
number: FormControl,
country: FormControl,
city: FormControl,
zipcode: FormControl
}
shippingAddressDifferentFromBillingAddress: FormControl
}
}
The advantage of using Template-driven forms is that Angular takes care of everything automatically for us.
The state is lost because it’s kept in the Template-driven Form that no longer has the shippingAddress
form group.
It was automatically destroyed together with the form control instances of the shippingAddress
.
The problem here is that when you select the checkbox, provide the shippingAddress
and toggle the checkbox to false
and true
again, that the state is lost.
Even though that is what you want in most cases, there are use-cases where we want to keep that state.
When the shippingAddressDifferentFromBillingAddress
property is set to false
the value of the form looks like:
{
"addresses": {
"billingAddress": {
"street": null,
"number": null,
"city": null,
"zipcode": null,
"country": null
},
"shippingAddressDifferentFromBillingAddress": false
}
}
When the shippingAddressDifferentFromBillingAddress
property is set to true
the value of the form looks like:
{
"addresses": {
"billingAddress": {
"street": null,
"number": null,
"city": null,
"zipcode": null,
"country": null
},
"shippingAddressDifferentFromBillingAddress": true,
"shippingAddress": {
"street": null,
"number": null,
"city": null,
"zipcode": null,
"country": null
}
}
}
Keeping state outside of the form
We can see that shippingAddress
is automatically added and removed.
When that could be seen as expected behavior, in some cases we do want to keep the state.
Since that state does not have anything to do with the validation state of the form we can extract it from that form entirely and put that in a signal.
export class MyFormComponent {
// Keep the shippingAddress in a local state signal
private readonly shippingAddress = signal<AddressFormModel({});
...
protected readonly viewModel = computed(() => ({
...
// Calculate a shippingAddress property that takes the shippingAddress
// from the formValue, and when it does not exist it should take it from
// the shippingAddress signal as a fallback, ensuring the persistance of the state
shippingAddress: this.formValue().addresses?.shippingAddress || this.shippingAddress()
}));
...
protected setFormValue(e: PurchaseFormModel): void {
this.formValue.set(e);
// if there is a shippingAddress, update the local state
if (e.addresses?.shippingAddress) {
this.shippingAddress.set(e.addresses.shippingAddress);
}
}
}
We just added a new signal containing the state, added a computed property called shippingAddress
and set the state if there was an update with the shippingAddress
.
Now let’s update the HTML to bind to shippingAddress
instead of the shippingAddress
living in formValue.addresses.shippingAddress
:
<form (formValueChange)="setFormValue($event)">
...
<div ngModelGroup="addresses">
...
<div *ngIf="vm.showShippingAddress">
<h3>Shipping address</h3>
<!-- use vm.shippingAddress directly -->
<app-address ... [address]="vm.shippingAddress"> </app-address>
</div>
</div>
</form>
Now Angular do the following things for us:
- When initially the checkbox is set to
false
:- There will be no
shippingAddress
form group. - There will be no form group for the
shippingAddress
.
- There will be no
- When the checkbox is set to
true
:- The
shippingAddress
form group and its form controls will be added. - All their potential validators will be executed.
- The
- When the user starts providing the
shippingAddress
, those values will be kept in state. - When the checkbox is set back to
false
.- The
shippingAddress
form group and all its form controls will be removed from the form. - The validation status of the form will be recalculated without the
shippingAddress
and the validators that were attached to it.
- The
But what if we want to keep the state in the form?
We generally would advise not to do that, but I will show you a few things you can try.
Keeping the state of an input in the form
For this example we are going to add a gender
property and a genderOther
property.
When the gender
(which is a radio button group) is set to other
then, the genderOther
control is added.
The code at this moment looks like this:
export class MyFormComponent {
...
protected readonly viewModel = computed(() => ({
...
showOtherGender: this.formValue().gender === 'other'
}));
...
}
<div>
<label>
<span>Gender</span>
Male
<input
type="radio"
[ngModel]="vm.formValue.gender"
name="gender"
value="male"
/>
Female
<input
type="radio"
[ngModel]="vm.formValue.gender"
name="gender"
value="female"
/>
Other
<input
type="radio"
[ngModel]="vm.formValue.gender"
name="gender"
value="other"
/>
</label>
</div>
<div>
<label>
<input
type="text"
[ngModel]="vm.formValue.genderOther"
name="genderOther"
*ngIf="vm.showOtherGender"
/>
</label>
</div>
We calculate whether the genderOther
input should be shown and in the HTML we bind 3 radio buttons to the gender
property of our formValue
.
We use an *ngIf
directive to hide the genderOther
based on the showOtherGender
and that’s it.
Now when we select other
in the gender
radiobutton and we type a value in the genderOther
, the value will be lost when we switch it back to female
or male
.
The goal here is to make sure that the value of genderOther
is maintained in the form, even when the value of gender
is not set to other
.
We can use the [hidden]
functionality of Angular and make sure the item is hidden with display: none
behind the scenes:
<div>
<label>
<input type="text" ... name="genderOther" [hidden]="!vm.showOtherGender" />
</label>
</div>
This would work but it might be more semantically correct to use a hidden field. For this we can use input type="hidden"
and conditionally set the input type
based on the result of the ViewModel:
<div>
<label>
<input
...
name="genderOther"
[attr.type]="vm.showOtherGender? 'text': 'hidden'"
/>
</label>
</div>
Hiding an entire block
Having a conditional input type might result in more boilerplate if we want to hide an entire group for instance. You could also use a hidden field to bind an entire form group to. We can do this by using the combination of a hidden field with [ngModel]
:
<div ngModelGroup="addresses">
<h3>Billing address</h3>
<app-address ...></app-address>
<input ... name="shippingAddressDifferentFromBillingAddress" />
<div *ngIf="vm.showShippingAddress; else shippingAddressState">
<h3>Shipping address</h3>
<app-address ngModelGroup="shippingAddress" [address]="vm.shippingAddress">
</app-address>
</div>
<!-- if it should be hidden
bind the ormValue().addresses?.shippingAddress
to the [ngModel]
-->
<ng-template #shippingAddressState>
<input
type="hidden"
[ngModel]="vm.formValue.addresses?.shippingAddress"
name="shippingAddress"
/>
</ng-template>
</div>
Wrap up
Check out the Complete Stackblitz solution
We learned that Template-driven Forms do all the work for us but also automatically remove the values of the removed controls from our form state. In some cases we don’t want that and we can work with a separate signal that holds the state value and calculate the computed value in the ViewModel.
We could also use a [hidden]
directive from Angular to hide an input from the DOM but it might be more semantically correct to use a hidden field.
After that we saw how we can bind an entire form group to a hidden field to keep our state. I hope you enjoyed this article! Stay tuned for more content! Interested in the YouTube video? Check it here
Update A full working solution with a complex Demo can be found here
Special thanks to the reviewer:
Follow @TwitterDevJoin the club and get notified with new content
Get notified about our future blog articles by subscribing with the button below.
We won't send spam, unsubscribe whenever you want.
Update cookies preferences