[Note: This article is a consolidation of the README.md files from my Nested Forms - An exploration of alternatives Angular exploration project.]
Nested Forms - An exploration of alternatives
The official Angular documentation discussing Reactive Forms shows how one can use groups and arrays to organize a form, and to dynamically add to it. The drawback of the example on that page is that everything is in a single component.
Once a component starts to grow in size and complexity, it becomes desirable to decompose it into multiple smaller components. At this point, there are a number of ways to break up the form into a set of nested form elements.
Project Purpose
This project explores several approaches to implementing nested forms/components. The project is structured using the Nrwl Nx monorepository framework, with shared models and services in a library, and separate applications to explore each approach.
Each application implements a simple contact management form, capturing simple demographics information and a list of postal addresses.
The full source code is available at https://git.thecorams.net/kevin/nested-forms.
[Update January 14, 2020: The code in the repository at the time this article was written has been tagged at tag/part_1]
Base Line - The Single component
The Base Line application implements the contact management form in a single angular component, directly using the example techniques from the Reactive Forms documentation.
Implementation
The easiest way to create a simple Reactive Form is to use a single Angular component to build and display the form. In this example application, an asynchronous call to an API is simulated. The Contact
object thus obtained is used to create and initialize a FormGroup
using the Angular FormBuilder
.
export class AppComponent implements OnInit, OnDestroy {
form: FormGroup;
private subscription: Subscription
public ngOnInit() {
this.subscription = this.service
.loadContact()
.subscribe((data: Contact) => {
this.form = this.createForm(data);
});
}
public createForm(model: Contact): FormGroup {
const name = model.name;
const addresses: FormArray = this.fb.array([]);
const group = this.fb.group({
name: this.fb.group({
firstName: [name ? name.firstName : ''],
lastName: [name ? name.lastName : ''],
middleName: [name ? name.middleName : ''],
prefix: [name ? name.prefix : ''],
suffix: [name ? name.suffix : ''],
}),
addresses: addresses,
});
if (model.addresses) {
model.addresses.forEach(addr => {
addresses.push(
this.fb.group({
line1: [addr ? addr.line1 : ''],
line2: [addr ? addr.line2 : ''],
city: [addr ? addr.city : ''],
state: [addr ? addr.state : ''],
postalCode: [addr ? addr.postalCode : ''],
}),
);
});
}
return group;
}
get addresses(): FormArray {
return this.form.get('addresses') as FormArray;
}
}
The component's template renders input controls for the Contact's name, and iterates over the array of addresses to render controls to edit each one.
<form *ngIf="form" [formGroup]="form">
<ng-container formGroupName="name">
<div>
<label for="firstName">First Name: </label>
<input name="firstName" formControlName="firstName">
</div>
<!-- etc for each attribute in the Contact name -->
</ng-container>
<ng-container formArrayName="addresses">
<ng-container *ngFor="let addr of addresses.controls; let i=index">
<!-- Note: the form group is bound to the index variable -->
<ng-container [formGroupName]="i">
<div>
<label for="line1">Line 1: </label>
<input name="line1" formControlName="line1">
</div>
<!-- etc for each attribute in the Address -->
</ng-container>
</ng-container>
</ng-container>
</form>
Pros
- All of the logic is in one place
- The potential complexity of multiple components is avoided
Cons
- The component and its template will become ever larger and more complex as the form grows
- Little-to-no opportunity for code reuse
Components Creating Own Form Controls
Searching the Internet for examples of nested forms in Angular uncovered one popular approach, having each sub-component be responsible for creating its own form controls. The parent form is passed in as an @Input
, and the component adds its components to the form using the addControl(name, control)
method. See, for example, Nested Reactive Forms in Angular2.
The Parent Form application explores this approach.
Implementation
When a component becomes sufficiently complex, or the developer wishes to be able to reuse parts of it elsewhere, the component can be decomposed into sub-components. One approach to binding such child sub-components to their containing parent component is to pass the parent component's FormGroup
in as an @Input
parameter. Additionally, the data that each child needs is passed into an @Input
. The children create their own FormControls
as needed, and add them to the parent FormGroup
provided to them. This approach greatly simplifies the code and template of the parent component:
export class AppComponent implements OnInit, OnDestroy {
contact: Contact;
form: FormGroup;
private subscription: Subscription
constructor(private fb: FormBuilder, private service: ContactService) {
this.form = this.fb.group({});
}
public ngOnInit() {
this.subscription = this.service.loadContact().subscribe((data: Contact) => {
this.contact = data;
});
}
}
<form [formGroup]="form">
<nested-forms-name
[name]="contact.name"
[parent]="form"
></nested-forms-name>
<nested-forms-address-list
[addresses]="contact.addresses"
[parent]="form"
></nested-forms-address-list>
</form>
The nested-forms-name
component is responsible for creating the form controls binding to the Contact's name, and the nested-forms-address-list
component is responsible for iterating over the Contact's addresses and binding to them using the nested-forms-address
(singular) component. For example, the nested-forms-name
would be implented as so:
@Component({
selector: 'nested-forms-name',
templateUrl: './name.component.html',
styleUrls: ['./name.component.css']
})
export class NameComponent implements OnInit {
@Input() name: Name;
@Input() parent: FormGroup;
group: FormGroup;
constructor(private fb: FormBuilder) {
}
ngOnInit() {
this.group = this.fb.group({
firstName: new FormControl(this.name ? this.name.firstName : ''),
lastName: new FormControl(this.name ? this.name.lastName : ''),
middleName: new FormControl(this.name ? this.name.middleName : ''),
prefix: new FormControl(this.name ? this.name.prefix : ''),
suffix: new FormControl(this.name ? this.name.suffix : ''),
});
if (this.parent) {
this.parent.addControl('name', this.group);
}
}
}
Calling this.parent.addControl(....)
is what ensures that the controls created in the child component are made part of the over-all form.
Pros
- The parent component is easy to understand and maintain
- Each child component encapsulates its form controls and template
- The child components can easily be re-used in other “parent” components
Cons
- The creation of the form controls is tightly coupled with the templates
- Since each child component encapsulates its form controls, the overall shape of the form data is not always clear
Parent Component Creates Form and Passes Form Controls Into Child Components
Another approach is to allow the outermost, or parent, component create the full Reactive Form. Each child component is given the FormGroup
containing the portion of the form that it is responsible for rendering.
The Global Form application explores this approach.
Implementation
An alternative approach to refactoring a component into child sub-components is to make the parent component be responsible for creating the entire Reactive Form, and to pass the appropriate form controls into the children. By also refactoring the logic for creating the form into a builder method in a separate service, the parent control's logic and template become almost as simple as in the Parent Form application above:
export class AppComponent implements OnInit, OnDestroy {
contact: Contact;
form: FormGroup;
private subscription: Subscription;
constructor(
private service: ContactService,
private formService: ContactFormService,
) {}
public ngOnInit() {
this.subscription = this.service
.loadContact()
.subscribe((data: Contact) => {
this.contact = data;
this.form = this.formService.createForm(data);
});
}
}
<form [formGroup]="form">
<nested-forms-name
[nameGroup]="form.get('name')"
></nested-forms-name>
<nested-forms-address-list
[addressArray]="form.get('addresses')"
></nested-forms-address-list>
</form>
While the templates for nested-forms-name
and nested-forms-address-list
using this approach are almost identical to the templates in the Own Form Controls example, the typescript code is even simpler:
@Component({
selector: 'nested-forms-name',
templateUrl: './name.component.html',
styleUrls: ['./name.component.css']
})
export class NameComponent {
@Input() nameGroup: FormGroup;
}
Form Builder Service
When looking at Reactive Forms through the lens of the Model, View, Controller pattern, the FormGroup
is the Model, and the HTML templating is the View. By using factory service methods to create the overall form structure, one can gain the benefits of encapsulation and composition while maintaining a strong separation between the Model and the View.
import { Injectable } from '@angular/core';
import { FormArray, FormBuilder, FormGroup } from '@angular/forms';
import { Contact, Name, Address } from '@nested-forms/contact';
@Injectable({
providedIn: 'root',
})
export class ContactFormService {
constructor(private fb: FormBuilder) {}
public createForm(model: Contact): FormGroup {
return this.fb.group({
name: this.createContactNameForm(model.name),
addresses: this.createContactAddressListForm(model.addresses),
});
}
public createContactNameForm(name: Name): FormGroup {
return this.fb.group({
firstName: [name ? name.firstName : ''],
lastName: [name ? name.lastName : ''],
middleName: [name ? name.middleName : ''],
prefix: [name ? name.prefix : ''],
suffix: [name ? name.suffix : ''],
})
}
public createContactAddressListForm(addresses: Address[]): FormArray {
const list: FormArray = this.fb.array([]);
if (addresses) {
addresses.forEach(addr => {
list.push(this.createContactAddressForm(addr));
});
}
return list;
}
public createContactAddressForm(addr: Address): FormGroup {
return this.fb.group({
line1: [addr ? addr.line1 : ''],
line2: [addr ? addr.line2 : ''],
city: [addr ? addr.city : ''],
state: [addr ? addr.state : ''],
postalCode: [addr ? addr.postalCode : ''],
});
}
}
Pros
- Clean separation between Model and View
- The parent component is easy to understand and maintain
- The child components are likewise easy to understand and maintain
- Encapsulation and composition are provided by the factory methods
Cons
- Having a service with factory methods adds complexity to the application
Conclusion
For forms complex enough to merit deconstruction into a composite of components, the Global Form pattern gives the best Model, View, Controller separation, while also providing decent encapsulation. Additionally, decoupling the form creation logic from the rendering of the form lends itself to better unit testability. Of the approaches to nested forms examined here, the Global Form pattern is the preferred one.
This article is licensed under a Creative Commons Attribution 4.0 (CC BY 4.0) License.
The code samples, like the linked Git repository's code they came from, are licensed under the MIT License.