This article demonstrates how to build a reusable form component that supports add and edit modes. We do this by using the container and presentation components architecture. The goal is not about teaching forms, so we keep it as simple as possible using reactive forms.
The form
We create a simplified form without any validation to add and edit medals. The medal has three properties:
export interface Medal {
name: string;
type: string;
sport: string;
}
With reactive forms, the [formGroup]
is a directive that we bind to and pass the form
object in:
<h1 *ngIf="!medal">Add Medal</h1>
<h1 *ngIf="medal">Edit Medal</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>Name</label>
<input type="text" formControlName="name" /><br />
<label>Type</label>
<input type="text" formControlName="type" /><br />
<label>Sport</label>
<input type="text" formControlName="sport" /><br />
<button type="submit">Submit</button>
</form>
We inject the FormBuilder
service and use the group()
method to create the form
controls matching the template:
import {
ChangeDetectionStrategy, Component, EventEmitter,
Input, OnInit, OnChanges, Output, SimpleChanges
} from '@angular/core';
import { FormGroup, FormBuilder } from '@angular/forms';
import { Medal } from '../app.component';
@Component({
selector: 'medal-form',
templateUrl: 'medal-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MedalFormComponent implements OnInit, OnChanges {
@Input() medal: Medal;
@Output() submitted = new EventEmitter<Medal>();
form: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.form = this.fb.group({
name: [''],
type: [null],
sport: [null],
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.medal?.currentValue) {
this.form?.patchValue(this.medal);
}
}
submit() {
this.submitted.emit(this.form.getRawValue());
this.form.reset();
}
}
We use the Input()
decorator for the medal
property. Then we use it when we have data that we can send to the child component. To watch for changes on an Input()
property, we use the OnChanges
lifecycle hook. Whenever it detects changes to the medal property, we populate the form with patchValue()
. When submit is pressed, we emit the form values through the Output()
property submitted
.
We have implemented our reusable form component as a dumb component. Now let us talk more about the architecture we chose and how we use the form component we created.
The problem area
Let us first consider why we want to split into these two components. When using only one, we need to subscribe()
to the observable data that Angular is keen for us to use. There are disadvantages to manually subscribing to observables. Some can lead to bugs that can be hard to debug. Using subscribe()
also requires us to unsubscribe at the end of the component lifecycle to avoid memory leaks.
Subscribing to the observable manually in the ngOnInit()
doesn't always work with the preferred OnPush
change detection strategy out of the box. We sometimes need to tell Angular change detection manually when we want it to run. Needless to say, when someone comes to me for help with code where the data is not updating for some reason, the first thing I do is look for a subscribe()
in the code.
Async pipe to the rescue?
The next, better solution is to use the async
pipe. But, this also has some downsides. Objects have to be unwrapped, sometimes multiple times, in the template using *ngIf="data$ | async as data"
.
Properties unwrapped using *ngIf
or *ngFor
are not accessible in the component's methods. We have to pass these properties to the methods from the template as method parameters which makes the code harder to read. And let me not get started about the testing.
So how can we then solve this better?
Smart/dumb components
For a better architecture, we split components into two types of specialized components:
- Smart components: also known as container components.
- Dumb components: also known as presentation components.
The responsibility of the dumb component is to present the data, while the smart one is responsible for fetching and managing the data. Presentation components should be child components of the container components on your page.
Interaction between smart and dumb components is done by:
-
Input
-presentation component receives data from parent -
Output
-presentation component triggers actions that parent listens to
By doing this, the presentation component remains isolated from the parent container component via a clearly defined interface.
Using the form
For the final piece of the puzzle, here are the ways we can use our presentational form as a create form:
<medal-form
(submitted)="onSubmitted($event)"
></medal-form>
We don't send any data in, so we get an empty form from which we get notified when it is submitted. Then the only thing left for us to do is call the backend through our store or service.
In the edit form we fetch the data and send it into the form through an async pipe:
<medal-form
[medal]="medal$ | async"
(submitted)="onSubmitted($event)"
></medal-form>
Now, we let the framework handle the subscriptions for us. The presentational component manages the data as objects instead of observables.
I have created a playground for you to play with the code. There is no data fetching or async pipe used, but it gives you an idea of how it works.
Conclusion
In this article, we combined two forms by making a presentational component. When we send it data with an async pipe, it receives the unwrapped object and populates the form with it. This architecture gives us a cleaner, more robust solution that hopefully keeps the bugs away.
Top comments (5)
Wonderful! First time I see somebody applying the container/presentational pattern to forms.
I do it myself all the time, but felt a little bit lonely.
Congratulations!
Thanks @rainerhahnekamp !
I find that the container/presenter pattern fits like a glove in the Angular framework and I try to get my team to use it as much as possible.
If I have a select input and I need to fetch options data from the backend, should I fetch them on the dumb or the smart component ?
Using a setter for form data Input is an option worth to consider.
Yes, I kind of go back and forth using setters and ngOnChanges.
Right now I'm on team OnChanges. :)