Cross-component Angular Forms
Most examples of Angular Reactive Forms are implemented as single components. But it does not have to be that way. Parts of a form can be implemented as a separate component, although there are some limitations to this. This makes it much easier to manage large, hierarchically structured forms.
Let us start with a simple form that contains one form array with a dynamic number of form groups:
<div class="form-container" [formGroup]="form">
<mat-form-field>
<input matInput placeholder="Group" formControlName="groupName" />
</mat-form-field>
<div formArrayName="members">
<div *ngFor="let member of members.controls; let i = index">
<span [formGroupName]="i">
<mat-form-field>
<input matInput placeholder="Member" formControlName="memberName" />
</mat-form-field>
</span>
<button mat-icon-button (click)="removeMember(i)">
<mat-icon>delete_forever</mat-icon>
</button>
</div>
<button mat-mini-fab (click)="addMember()">
<mat-icon>add</mat-icon>
</button>
</div>
</div>
The code initializes the top form group and has a few helper methods for handling the array:
export class AppComponent {
form = this.formBuilder.group({
groupName: ["", Validators.required],
members: this.formBuilder.array([]),
});
constructor(private formBuilder: FormBuilder) {}
get members() {
return this.form.get("members") as FormArray;
}
addMember() {
this.members.push(
this.formBuilder.group({
memberName: ["", Validators.required],
})
);
}
removeMember(index: number) {
this.members.removeAt(index);
}
}
It is likely that the form groups within the array will be more complex in a real-world scenario. Therefore, they are a good candidate for implementation as a separate component. A naïve approach would simply move the markup inside the loop to a separate component:
<span [formGroupName]="formGroupName">
<mat-form-field>
<input matInput placeholder="Member" formControlName="memberName" />
</mat-form-field>
</span>
The formGroupName
must be declared as a component input:
export class MemberComponent {
@Input() formGroupName: string | number | null = null;
}
Now the inner markup in the parent component can be replaced by this new component:
<div class="form-container" [formGroup]="form">
<mat-form-field>
<input matInput placeholder="Group" formControlName="groupName" />
</mat-form-field>
<div formArrayName="members">
<div *ngFor="let member of members.controls; let i = index">
<app-member [formGroupName]="i"></app-member>
<button mat-icon-button (click)="removeMember(i)">
<mat-icon>delete_forever</mat-icon>
</button>
</div>
<button mat-mini-fab (click)="addMember()">
<mat-icon>add</mat-icon>
</button>
</div>
</div>
Unfortunately, this fails with the following error message:
Error: NG01053: formGroupName must be used with a parent formGroup directive. You'll want to add a formGroup directive and pass it an existing FormGroup instance (you can create one in your class).
The problem is that you cannot use any of the form*Name
directives without a parent formGroup
directive in the same component. And that is exactly what we are doing in our MemberComponent
. To fix the problem, we need to use formGroup
instead of formGroupName
:
<span *ngIf="formGroup" [formGroup]="formGroup">
<mat-form-field>
<input matInput placeholder="Member" formControlName="memberName" />
</mat-form-field>
</span>
The component input also needs to be updated accordingly:
export class MemberComponent {
@Input() formGroup?: FormGroup;
}
In the parent component, we already have a reference to the form group in the member
loop variable. So we can pass this to the nested component:
<app-member [formGroup]="member"></app-member>
However, if you use strict mode for template type checking, this will not work. The compiler will complain that the loop variable has the wrong type:
Type
AbstractControl<any, any>
is not assignable to typeFormGroup<any>
.
The type is correct at runtime and the code would work if it were compiled. But the compiler does not know that, so we have to explicitly cast the loop variable to the correct type. For this we can write a helper method:
getMember(index: number) {
return this.members.at(index) as FormGroup;
}
This method returns the array element at the specified index that has been cast to the correct type. We should call it from the template instead of just passing the loop variable:
<app-member [formGroup]="getMember(i)"></app-member>
We finally have a working solution. But it is not completely type safe. So if we are not careful, we could introduce code changes that cause runtime errors because the compiler does not detect type errors.
Before Angular 14, that was the best we could do. But with the strictly typed forms introduced in Angular 14, we can do better. We can define a type for the form groups that we will add to the form array:
export type MemberFormGroup = FormGroup<{
memberName: FormControl<string | null>;
}>;
We can then use this type when creating the form array:
form = this.formBuilder.group({
groupName: ["", Validators.required],
members: this.formBuilder.array<MemberFormGroup>([]),
});
To access the strictly typed form array, we need to update our helper getter:
get members(): FormArray<MemberFormGroup> {
return this.form.controls.members;
}
Now we can pass the loop variable to our component without error:
<app-member [formGroup]="member"></app-member>
We should also update the type of the component input to make it strongly typed:
export class MemberComponent {
@Input() formGroup?: MemberFormGroup;
}
You can find a working example project in my GitHub repository. The last commit contains working code for Angular 14. The previous commits follow the steps described in this post.
Most Reactive Forms examples show you how to create a form as a single component. In more complex real-world scenarios, you may want to split such a form into multiple components. In this post, I have described how to troubleshoot common errors that can occur during this process. I also showed how you can make your Reactive Forms code more strictly typed when using Angular 14.