Building a Reusable Form for Create and Edit Operations
This guide walks through creating a single form component that works for both adding and editing records. The approach relies on the container and presentation component pattern. Since forms themselves are not the focus here, we keep things straightforward with reactive forms and no validation.
Defining the Form Structure
We start with a minimal form that has no validation, intended for adding or editing medals. A medal includes three properties:
export interface Medal {
name: string;
type: string;
sport: string;
}
In reactive forms, the [formGroup] directive binds the template to our form model:
<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>
The FormBuilder service is injected, and its group() method defines the form controls that correspond to 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();
}
}
The medal property is marked with the Input() decorator, allowing the parent to pass data down. The OnChanges lifecycle hook watches for updates to this input; when a change is detected, the form gets populated via patchValue(). Upon submission, the form values are emitted outward through the Output() property called submitted.
This form component is intentionally a dumb component. Next, we explain the reasoning behind this design and demonstrate how the form is integrated.
Why the Split Is Needed
Let us examine the rationale for separating responsibilities into distinct components. If we relied on a single component, we would have to call subscribe() on an observable stream. Manually subscribing to observables brings several drawbacks; it can introduce subtle bugs that are tough to trace. It also places the burden on us to explicitly unsubscribe during teardown, otherwise we risk memory leaks.
Additionally, using subscribe() inside ngOnInit() does not play nicely with the OnPush change detection strategy by default. We would often have to manually request change detection. In practice, when someone reports that their data is not refreshing, my first instinct is to scan for subscribe() calls in the codebase.
Is the Async Pipe a Better Option?
An improvement over manual subscription is leveraging the async pipe. However, it does come with its own set of limitations. Objects must be unwrapped, often multiple times per template, using syntax like *ngIf="data$ | async as data".
Those unwrapped properties are not available inside the component's methods directly. Instead, you have to pass them as arguments from the template, which can make the code cumbersome to read. And we will not even get into the difficulties this creates for unit testing.
So what is a more robust approach?
Separating Smart and Dumb Components
An effective architecture divides components into two distinct categories:
- Smart components: frequently referred to as container components.
- Dumb components: also called presentation components.
The presentation component focuses solely on rendering data, while the container component is responsible for retrieving and managing that data. Presentation components should be nested within their corresponding container components.
Communication between the two is achieved via:
-
Input-the presentation component receives data from its parent -
Output-the presentation component emits events for the parent to react to
This separation ensures the presentation component stays decoupled from the container, communicating only through a clearly defined interface.
Putting the Form to Work
As the final step, here is how the presentational form is utilized for a create operation:
<medal-form
(submitted)="onSubmitted($event)"
></medal-form>
There is no input data, so the form renders empty, and the parent is notified when it is submitted. All that remains is to forward the submitted data to the backend via a store or service.
For the edit scenario, the data is loaded and passed to the form using the async pipe:
<medal-form
[medal]="medal$ | async"
(submitted)="onSubmitted($event)"
></medal-form>
Now, all subscription management is handled by the framework. The presentational component works with plain objects rather than observable streams.
A playground is available to experiment with the code. Note that it does not involve data fetching or the async pipe, but it does illustrate the core mechanism.
Recap
We have successfully merged the add and edit forms into a single presentational component. When data flows in via the async pipe, the component receives an unwrapped object and fills the form with it. This pattern leads to cleaner, more reliable code and helps steer clear of common pitfalls.
