Creating a Dynamic Form Page in Angular: A Case Study
Tags: angular, typescript, frontend
Published: 2024-05-18 22:35:32

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.

```html
 <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.

```typescript
@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.

```typescript
  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.

```typescript
  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.

```html
<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.

```typescript
@ViewChild('container', { read: ViewContainerRef, static: true })
  container!: ViewContainerRef;
```

```typescript
  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.

```typescript
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](https://github.com/mateuscechetto/example-dynamic-forms).