DEV Community

Mateus Cechetto
Mateus Cechetto

Posted on

Creating a Dynamic Form Page in Angular: A Case Study

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>
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
      },
    },
  };
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
  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,
    },
  };
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

That's it! You can see the sample project on my GitHub.

Top comments (0)