Dynamic Reactive Forms with Angular

This academic year, I was the co-promotor of two students at the school I graduated last year. Sometimes, they needed my help because they were struggling with something for already a few days. One of these things was the creation of a form in Angular where the questions were loaded from a .NET Core back end. They were having problems to display the questions because they didn’t know how many questions they needed to display. Also, with the radio button questions, they had problems since they didn’t know how many possible options they needed to display. I created a demo application to help them with creating this dynamic reactive form.

I adjusted this demo to make it more generic and blog about it here since it may be useful for others. The styling of the form is done with Angular Material.

To create an application with Angular Material (and optionally create a custom theme), you can view my post Creating themes with Angular Material.

Adding Reactive Forms and Angular Material Modules

To build a dynamic form in Angular, we need to define the template of our form in code. This can be done with reactive forms. The application needs access to the reactive forms directive by importing ReactiveFormsModule in the root module from your application.

In my example, this is in app.module.ts. Here we also add the modules from Angular Material that we need.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatRadioModule } from '@angular/material/radio';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    ReactiveFormsModule,
    MatRadioModule,
    MatInputModule,
    MatButtonModule,
    MatFormFieldModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Creating the form

We created 3 models for the form: a model for the question, an enum for the question types and a model for the answers to send the answers back to the backend.

import { QuestionType } from './questiontype.enum';

export class Question {
    questionId: number;
    question: string;
    required: boolean;
    questiontype: QuestionType;
    possibleAnswers?: string[];
    answer: string;

    constructor(options: {
        questionId: number,
        question: string,
        required?: boolean,
        questiontype?: string,
        possibleAnswers?: string[],
        answer?: string
      }) {
      this.questionId = options.questionId;
      this.question = options.question;
      this.required = !!options.required;
      this.questiontype = QuestionType[options.questiontype];
      this.possibleAnswers = options.possibleAnswers || null;
      this.answer = options.answer;
    }
}
export enum QuestionType {
    open = 'Open',
    radio = 'Radio'
}
export class QuestionAnswers {
    questionId: number;
    answer: string;

    constructor(options: {
        questionId: number,
        answer: string
      }) {
      this.questionId = options.questionId;
      this.answer = options.answer;
    }
}

I made a service to get the questions. In this demo, I hard-coded the questions in this service. In real-world applications, you obviously fetch the questions from a back end in this service.

import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { Question } from './question.model';

@Injectable({
  providedIn: 'root'
})
export class QuestionService {

  public getQuestions(): Observable<Question[]> {
    let questions: Question[] = [
      new Question({
        questionId: 1,
        question: 'Firstname',
        required: true,
        questiontype: 'open'
      }),
      new Question({
        questionId: 2,
        question: 'Lastname',
        required: true,
        questiontype: 'open'
      }),
      new Question({
        questionId: 3,
        question: 'Gender',
        questiontype: 'radio',
        possibleAnswers: ['Male', 'Female', 'Other']
      }),
      new Question({
        questionId: 4,
        question: 'Favorite day of the week',
        questiontype: 'radio',
        possibleAnswers: ['Monday', 'Tuesday', 'Wednesday','Thursday', 'Friday', 'Saturday', 'Sunday']
      })
    ];

    return of(questions);
  }
}

To create a reactive form, we need to group the form fields and their validators in a FormGroup. Here I loop over the questions and create a new FormControl for every question and add them to the FormGroup. The FormControl needs a unique FormControlName. In this example, the questionId is a unique identifier for the questions, so I used this. The first value in the FormControl constructor is the initial form value and the second is the validator or array of validators for that form field.

let group: any = {};

this.questions.forEach(question => {
      group[question.questionId] = question.required ? new FormControl(question.answer, Validators.required)
                                                     : new FormControl(question.answer);
});
this.questionsForm = new FormGroup(group);

To display the questions depending on their type, we use the ngSwitch directive to only show the template for that question-type.

We use the [formGroup] directive and the [formControlName] directive for the binding to respectively the formgroup and the formcontrol. By using the validation on the formcontrol, we can show an error when the validation fails.

This gives this in our example html-file:

<div *ngIf="questionsForm" class="container">
  <h2>{{title}}</h2>
  <form (ngSubmit)="onSubmit()" [formGroup]="questionsForm">
    <div *ngFor="let question of questions">
      <div [ngSwitch]="question.questiontype">
        <mat-form-field *ngSwitchCase="questionType.open">
          <mat-label>{{question.question}}</mat-label>
          <input matInput [value]="question.answer" [formControlName]="question.questionId">
        </mat-form-field>

        <div *ngSwitchCase="questionType.radio" >
          <mat-label>{{question.question}}</mat-label>
          <mat-radio-group [id]="question.questionId" [formControlName]="question.questionId">
            <mat-radio-button *ngFor="let option of question.possibleAnswers" [value]="option">{{option}}</mat-radio-button>
          </mat-radio-group>
        </div>

        <mat-error *ngIf="questionsForm.controls[question.questionId.toString()].touched && questionsForm.controls[question.questionId.toString()].invalid">{{question.question}} is required</mat-error>
      </div>
    </div>
    <div>
      <button mat-raised-button color="primary" type="submit" [disabled]="!questionsForm.valid">Save</button>
    </div>
  </form>

  <div *ngIf="answers">
    <strong>provided answers: </strong><br>{{answers | json}}
  </div>
</div>

When we submit the answers, we create a new answers-array that we now show in the front end, but in a real-world application, we can send this to a back end. Our complete typescript file is this:

import { Component, OnInit } from '@angular/core';
import { QuestionService } from './question.service';
import { Question } from './question.model';
import { QuestionAnswers } from './questionAnswers.model';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { QuestionType } from './questiontype.enum';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit{
  title = "Dynamic Reactive Forms With Angular and Angular Material";
  questions: Question[];
  answers: QuestionAnswers[];
  questionsForm: FormGroup;
  questionType = QuestionType;

  constructor(private questionService: QuestionService) {
  }

  public ngOnInit(): void {
    this.getQuestions();
  }

   public onSubmit(): void {
    this.answers = [];
    this.questions.forEach(question => {
      this.answers.push(new QuestionAnswers({
        questionId: question.questionId,
        answer: this.questionsForm.get(question.questionId.toString()).value
      }));
    });
  }

  private getQuestions(): void {
    this.questionService
    .getQuestions()
    .subscribe(questions => {
      this.questions = questions;
      this.initForm();
    })
  }

  private initForm(): void {
    let group: any = {};

    this.questions.forEach(question => {
      group[question.questionId] = question.required ? new FormControl(question.answer, Validators.required)
                                                     : new FormControl(question.answer);
    });
    this.questionsForm = new FormGroup(group);
  }
}

Seeing it in action

When we run the sample and touch the input fields, you see the errors. The save button is also only enabled when the form is valid.

Validation errors

When the form is valid, and you click the save button, you see that the answers are displayed below.

valid form submission

You can find the code on my GitHub.

Lindsey is a .NET Consultant at eMenKa NV where she is focusing on web development. She is a crew-member of Techorama Belgium and the Netherlands and she runs VISUG, The Visual Studio User Group in Belgium.
Leave a Reply

Your email address will not be published. Required fields are marked *