Recently, my team received a task: create a form page. Sounds easy, doesn't it? After all, we were experienced with Angular and had built many forms. However, there was one big problem: the form page was separated into two parts—a top form and a bottom form. The top form had one dropdown field that, depending on its value, would cause the bottom form to have completely different fields. Because of a design decision to keep the save button always in the same position, the save button was located on the top form. Additionally, the save button didn't just submit the form values; it also performed other actions depending on the type of the bottom form.
Initial Approach
Our first approach was creating a smart component for the page and and dumb components for the forms that would be conditionally rendered.
<select
name="forms"
id="forms"
[(ngModel)]="selected"
(ngModelChange)="onChangeOption($event)"
>
@for(form of options; track form) {
<option value="{{ form }}">{{ form }}</option>
}
</select>
<ng-container [ngSwitch]="selected">
<ng-container *ngSwitchCase="'form1'">
<app-form-1></app-form-1>
</ng-container>
<ng-container *ngSwitchCase="'form2'">
<app-form-2></app-form-2>
</ng-container>
<ng-container *ngSwitchCase="'form3'">
<app-form-3></app-form-3>
</ng-container>
<ng-container *ngSwitchCase="'form4'">
<app-form-4></app-form-4>
</ng-container>
</ng-container>
Challenges Faced
Since the save button was on the parent (page) component, we needed to access the information in the active child (form) component. For that, we created the FormGroup in the parent component and passed it through @Input to the child components. This approach led to a new problem: when an option was selected on the top form, some fields on the child component were filled. Then, if the option on the top form was changed, the values from the old child component stayed on the form.
The Solution
To solve this, we found two options: remove the fields from the form in each child component's ngOnDestroy, or create a different FormGroup for each child component. We chose the latter because it was more error-proof, on the first a developer can forget to clean up in the ngOnDestroy of a new form.
So, when the option is changed, we change the activeForm to the corresponding form.
And when the save button is clicked, we have to see the selected option and do the correspondent action.
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
options: FORM[] = ['form1', 'form2', 'form3', 'form4'];
selected = this.options[0];
form1 = new FormGroup({});
form2 = new FormGroup({});
form3 = new FormGroup({});
form4 = new FormGroup({});
activeForm: FormGroup = this.form1;
onChangeOption(option: FORM) {
switch (option) {
case 'form1':
this.activeForm = this.form1;
break;
case 'form2':
this.activeForm = this.form2;
break;
case 'form3':
this.activeForm = this.form3;
break;
case 'form4':
this.activeForm = this.form4;
break;
}
}
onSave() {
switch (this.selected) {
case 'form1':
console.log('save 1');
break;
case 'form2':
console.log('save 2');
break;
case 'form3':
console.log('save 3');
break;
case 'form4':
console.log('save 4');
break;
}
}
}
Have you noticed a pattern? Each time we wanted to do something related to a form, we had to use a switch case to find out which form was active. This approach was not only verbose but also error-prone in the case of adding a new form. How did we solve it?
Lookup Tables to the rescue
A lookup table is a common JS/TS pattern that helps us avoid multiple switch cases. Instead of using branches, we create an object with the keys being the options and the values being what we will do when the option is chosen. Here, TypeScript comes in very handy with the utility type Record<Keys, Type>. By using a type (or an enum) as the keys of the object, TypeScript tells us if any value is missing.
forms: Record<FORM, { form: FormGroup; save: () => void }> = {
form1: {
form: new FormGroup({}),
save: () => {
console.log('save 1');
},
},
form2: {
form: new FormGroup({}),
save: () => {
console.log('save 2');
},
},
form3: {
form: new FormGroup({}),
save: () => {
console.log('save 3');
},
},
form4: {
form: new FormGroup({}),
save: () => {
console.log('save 4');
},
},
};
This way, we could replace the switch cases in onChangeOption() and onSave() with the lookup table.
onChangeOption(option: FORM) {
this.activeForm = this.forms[this.selected].form;
}
onSave() {
this.forms[this.selected].save();
}
Now, we eliminated the switch cases from our TS file, and our code looks cleaner and more error-proof. But we still had the switch in our template.
Rendering the Forms Dynamically
We created a container that would render the desired form component. For the HTML, that's all.
<ng-container #container></ng-container>
Now, back in the TS file, we needed to get a reference to that container and add the components we wanted to render to the lookup table.
@ViewChild('container', { read: ViewContainerRef, static: true })
container!: ViewContainerRef;
forms: Record<
FORM,
{ form: FormGroup; save: () => void; component: Type<any> }
> = {
form1: {
form: new FormGroup({}),
save: () => {
console.log('save 1');
},
component: Form1Component,
},
form2: {
form: new FormGroup({}),
save: () => {
console.log('save 2');
},
component: Form2Component,
},
form3: {
form: new FormGroup({}),
save: () => {
console.log('save 3');
},
component: Form3Component,
},
form4: {
form: new FormGroup({}),
save: () => {
console.log('save 4');
},
component: Form4Component,
},
};
When we want to change the form to be rendered, we clear the container, then create the desired form component and pass its inputs.
onChangeOption(option: FORM) {
this.container.clear();
const componentRef = this.container.createComponent(
this.forms[option].component
);
componentRef.instance.form = this.forms[option].form;
}
That's it! You can see the sample project on my GitHub.
Top comments (0)