Before reading
The approach described in this article is not a one-size-fits-all solution. It works well for some use cases, but may not be the best solution for your use case. I also wrote about another approach for handling complex forms: Building complex forms with Angular Reactive Forms.
This article is based on Angular 14 and will not be updated anymore.
Note
Does your form have a lot of cross-field validations and side effects? I recommend my new article Building complex forms with Angular Reactive Forms which works better for such use cases.
Before reading this article you should be familiar with Reactive Forms and with strictly typed forms (if you use Angular 14+).
Reactive Forms are a great feature of Angular but building large, complex forms is not easy. As your form grows you'll end up with a large and hard to maintain component. Therefore, it's better to split your form into multiple components.
In this article we'll implement a clean, easy and maintainable solution for large forms. We'll use strictly typed reactive forms (a new feature in Angular 14) to build a type-safe form. If you use an older version of Angular you can just omit the types, everything else is the same.
Architecture
Before implementing the solution we take a look at the architecture of our reactive form. We need a way to split our form into multiple parts (and components) and we need to handle data, like initializing the form and saving the data.
Form
For our solution we are going to split our form into multiple child components. Each child component will be responsible for an encapsulated part of the form and will create and handle its own form group.
In the parent component we'll combine the form groups of all child components into a main form group to check validation status (and e.g. disabling the submit button) or resetting the form.
With this approach each child component is fully responsible for its part of the form and can handle all form related stuff like validation and data transformation (e.g. initializing the form values and extracting the form values).
The parent does not need to care about the implementation details of each child component (like how the form is structured) and is just responsible for providing the initial data and submitting the form.
Data handling
You may want to initialize the form with some existing data. Simply calling form.setValue(existingData)
is not enough as your form may not have the same structure as your data object. For example, your data object may have a birthday
property while your form needs three properties to split it into day, month and year (and rendering a dropdown for each). Therefore, you need some logic to transform your data object into the form structure. When submitting the form you need to extract the form values and transform them back into your data object.
You could do that work in the parent component. This component has access to the whole form and can read and write all values. This is fine for smaller, less complex forms (and with strictly typed forms this solution is not that bad) but as your form grows this becomes a hard to maintain solution. Your parent component will have to handle all the data transformation and need to know how the form groups of your child components are structured.
It's better to keep this logic in each child component. This makes adding new child components or refactoring them easier. The parent component should provide the initial data to each child component and the child component can initialize the form values. Whenever the form value changes, each child component can extract its own form values and provide them to the parent component.
Implementation
Now that we know how the solution looks like we can start implementing it. For this demo we are going to implement a simple profile form that allows the user to edit his profile (name, email, GitHub username, etc.).
Child component
Let's start with a child component that is responsible for a part of the form. We create a new component and call it NameSubformComponent
. The component injects the FormBuilder
and creates a new form group with their form controls, validators and any other configuration. We add a formReady
event that provides the created form group to the parent component.
export class NameSubformComponent {
form = this.fb.nonNullable.group({
firstName: ["", Validators.required],
lastName: ["", Validators.required],
});
@Output()
formReady = of(this.form);
constructor(private fb: FormBuilder) {}
}
Because RxJS observables are compatible with Angular EventEmitters we can create an observable with of()
that emits the created form group and use it as an output. If you don't like this pattern you can create a normal EventEmitter and emit an event with the form group in ngOnInit()
:
export class NameSubformComponent implements OnInit {
// ...
@Output()
formReady = new EventEmitter<typeof this.form>();
ngOnInit() {
this.formReady.emit(this.form);
}
}
Next we want to receive the initial data from the parent component and initialize the form values. For that we create an input property with a setter that updates the form. Here you could do any data transformation you need.
export class NameSubformComponent {
// ...
@Input()
set initialUser(user: User) {
this.form.patchValue({
firstName: user.firstName,
lastName: user.lastName,
});
}
}
Then we need to extract the form values and provide them to the parent component whenever a value changes. And again we can provide an observable as @Output()
instead of creating an event emitter:
export class NameSubformComponent {
// ...
@Output()
valueChange = defer(() =>
this.form.valueChanges.pipe(
startWith(this.form.value),
map(
(formValue): Partial<User> => ({
firstName: formValue.firstName,
lastName: formValue.lastName,
})
)
)
);
}
There are a few important things to note here: form.valueChanges
will only emit when the form value changes but not initially. That's why we use startWith
to provide the initial value. And we use defer()
to use the latest form value for startWith()
whenever someone subscribes. Technically it would work without this, but it's definitely safer to use defer()
here. The observable emits a partial User
object that only contains the properties related to our part of the form (here firstName and lastName).
And finally we implement our component template. I create a very simple form with Angular Material for this demo. For strictly typed forms (Angular 14+) I prefer formControl
over formGroup
+ formControlName
because it's type-safe.
<mat-form-field>
<mat-label>First name</mat-label>
<input matInput [formControl]="form.controls.firstName" required />
</mat-form-field>
<mat-form-field>
<mat-label>Last name</mat-label>
<input matInput [formControl]="form.controls.lastName" required />
</mat-form-field>
Create more child components the same way for the other form parts. The demo includes a second child component, but I omit that code here as it works exactly the same. Next, we can create the parent component.
Parent component
The parent component renders the child components, provides the initial data, listens for changes and creates a form group to combine all child form groups. And it's responsible for submitting the form.
Form types
Our main form group should combine the form groups of all child components, each with a unique name. For better type-safety we create an interface for our main form group:
import { ObservedValueOf } from "rxjs";
import { ContactSubformComponent } from "../contact-subform/contact-subform.component";
import { NameSubformComponent } from "../name-subform/name-subform.component";
interface ProfileForm {
name?: ObservedValueOf<NameSubformComponent["formReady"]>;
contact?: ObservedValueOf<ContactSubformComponent["formReady"]>;
}
In the example above we have two child components, NameSubformComponent
and ContactSubformComponent
, which are combined into the main form group as name
and contact
. We define name
and contact
as optional because initially our form group will be empty until the first child component has emitted its formReady
event.
Note that I use the formReady
event to extract the type of each form group. We could use NameSubformComponent['form']
instead (without ObservedValueOf
) but the formReady
event is already a public API of the child component while I would consider the form
property to be a private API (though it's technically public
to be available in the template) and implementation detail of the component that should not be used in the parent.
Main form group
Next we create our form group using the interface above:
export class ProfileFormComponent {
form = this.fb.group<ProfileForm>({
// Form is empty for now -> child form groups will be added dynamically
});
constructor(private fb: FormBuilder) {}
}
Then we need a method to register the child form groups. The method accepts a name (here "name" or "contact") and the form group. Thanks to TypeScript this is fully typed - the name and the group must match the form interface. Providing an invalid name or a form group that doesn't match the name would result in an error.
export class ProfileFormComponent {
// ...
addChildForm<K extends keyof ProfileForm>(
name: K,
group: Exclude<ProfileForm[K], undefined>
) {
this.form.setControl(name, group);
}
}
And in our template we can render all child components and register the formReady
event.
<form>
<app-name-subform
(formReady)="addChildForm('name', $event)"
></app-name-subform>
<app-contact-subform
(formReady)="addChildForm('contact', $event)"
></app-contact-subform>
</form>
Initial data
Next we want to load the initial data via service and provide it to the child components. Assuming that we have a User
object I call that property initialUser
:
export class ProfileFormComponent implements OnInit {
// ...
initialUser?: User;
ngOnInit(): void {
this.userService.fetchUser().subscribe((user) => {
this.initialUser = user;
});
}
}
In the template we wait until the initialUser
is ready and then render the form. We also provide the value to each child component:
<form *ngIf="initialUser">
<app-name-subform
[initialUser]="initialUser"
(formReady)="..."
></app-name-subform>
<app-contact-subform
[initialUser]="initialUser"
(formReady)="..."
></app-contact-subform>
</form>
In our child components we already implemented the initialUser
input property setter that initializes the form group with the initial data.
Value changes
Our parent component should listen to any value changes in the child components.
For that we create another user
property that will contain the latest value and add a patchUser()
method to update the user.
export class ProfileFormComponent implements OnInit {
// ...
user?: User;
ngOnInit(): void {
this.userService.fetchUser().subscribe((user) => {
this.initialUser = user;
this.user = user; // Also initialize the user
});
}
patchUser(patch: Partial<User>) {
if (!this.user) throw new Error("Missing user");
this.user = { ...this.user, ...patch };
}
}
And we add the event listener to each child component and call the patchUser()
method when the form value changes:
<form *ngIf="initialUser">
<app-name-subform
[initialUser]="..."
(formReady)="..."
(valueChange)="patchUser($event)"
></app-name-subform>
<app-contact-subform
[initialUser]="..."
(formReady)="..."
(valueChange)="patchUser($event)"
></app-contact-subform>
</form>
Submit
Finally we can implement the submit event handler. We can also disable the submit button when the form is invalid (note: for a11y reasons this is actually not a good idea but still widely used and for demo purposes I will do that here).
<form *ngIf="initialUser" (ngSubmit)="onSubmit()">
<!-- ... -->
<button type="submit" [disabled]="form.invalid">Save</button>
</form>
Our onSubmit()
method can just use the existing user
property that contains the latest value and send it to the backend.
There is no need to access the form value as all child components always provide the latest value when it changes.
export class ProfileFormComponent implements OnInit {
// ...
onSubmit() {
if (!this.user) throw new Error("Missing user");
this.userService.updateUser(this.user).subscribe(() => {
// Show success message or redirect or ...
});
}
}
Additional thoughts
Accessing the form
The parent component has access to the whole form via its own main form group. This is great for checking the validation status or resetting the form.
While you could technically access specific controls of a child component (and reading or writing values) - and with our approach this is even type-safe - I don't recommend that in most cases. The structure of each form group is an internal API of each child component and the parent should not know about it. But it's possible and may be useful in some rare exceptional cases.
Cross-component form dependencies
You may have a use case where one child component depends on the form values of another child component. For example, if a checkbox in one child component was checked, a field in another child component should be disabled.
In such a scenario I recommend that you provide the user
object as input to the child component (in addition to initialUser
) which gives the component access to the latest user object. The child component can use this object to check the current value and disable the field. That prevents you from having dependencies between the components and their form groups.
Demo
The example is deployed here:
angular-large-typed-reactive-forms.blog.sandroroth.com
The source code is available on GitHub:
github.com/rothsandro/angular-large-typed-reactive-forms-example
Alternatives
Let's take a quick look at two alternatives for building large forms.
Control Value Accessor
The ControlValueAccessor
(CVA) interface (see CVA docs) allows you to build components that support reactive forms. If a component implements the interface you can use formControlName
on that component.
<form [formGroup]="form">
<!-- ChildComponent needs to implement ControlValueAccessor -->
<child-component formControlName="child"></child-component>
</form>
It's a clean approach and is a good way for building form field components, like a custom input field or a date picker. But for large forms I don't recommend this approach because it adds unnecessary complexity with an unfamiliar API and has some limitations, like resetting the form does not work (and the issue for supporting form reset was created 2017 and is still open).
Parent provides FormGroup to child
This approach is similar to the one we implemented but with a reversed responsibility. Here the parent component creates a form group, provides it to all child components and each child component registers their controls/groups on this shared form group.
There are a few problems with this approach:
- There is a high risk that two child components use the same name for a control/group and will overwrite each other.
- The child components manipulate the input value (by calling
addControl()
on the parent form group) which doesn't respect the one-way data flow architecture. - The child components give up their responsibility of managing their own form group by relying on the parent to provide a form group.
This approach is described in detail in this Stack Overflow answer.
Summary
That's it, we now have created a strictly typed reactive form that is split into multiple child components. Thanks to that architecture even large forms are easy to build and maintain. Each child component is responsible for its own form group and controls and independent of each other.
The new strictly typed reactive forms in Angular 14 improve the developer experience and reduce the risk of bugs.
I hope this article helped you to build a large reactive form. Let me know if you have any questions or suggestions.