Dynamic Nested Forms Angular Explained

In this post, you will learn how to create dynamic nested forms in Angular.

You likely already know how to create a form in Angular using reactive forms. It is extremely easy to do when the form is simple. But what can we do if we have some dynamic values that we want to insert into the form?

initial project

Let's say we want to create a quiz form, where we have a list of questions and each question can have multiple answers. How can we implement such a form?

Here, I have prepared the quiz form component, which is almost empty. The only thing I have prepared is some CSS to make it look a bit better.

<!-- src/app/quizForm/quizForm.component.css -->
.quiz-form {
  text-align: center;
  width: 400px;
  margin: 0 auto;
}

.question {
  border: 1px solid black;
  padding: 20px;
  margin-bottom: 20px;
}

.answers {
  margin-top: 10px;
}

.answer {
  margin-top: 10px;
}

.add-answer {
  margin-top: 10px;
}

.add-question {
  margin-bottom: 10px;
}

.remove {
  margin-left: 10px;
}

Setting up a form

First, we want to inject Reactive Forms and FormBuilder into our component to create a basic form.

// src/app/quizForm/quizForm.component.ts
@Component({
  selector: 'quiz-form',
  standalone: true,
  templateUrl: './quizForm.component.html',
  styleUrl: './quizForm.component.css',
  imports: [ReactiveFormsModule],
})
export class QuizFormComponent {
  fb = inject(NonNullableFormBuilder);
  quizForm = this.fb.group({
    questions: this.fb.array([]),
  });
}

Here, we created a form by calling this.fb.group, which has an array of questions inside. However, there's a problem. We want to fully type our form to ensure it works correctly.

// src/app/quizForm/quizForm.component.ts
type FormAnswer = FormGroup<{ text: FormControl<string> }>;

type FormQuestion = FormGroup<{
  questionName: FormControl<string>;
  answers: FormArray<FormAnswer>;
}>;

type Form = FormGroup<{
  questions: FormArray<FormQuestion>;
}>;

I added FormAnswer, FormQuestion, and Form to our data types to define all possible fields of the form. FormAnswer is an object that contains a text property. FormQuestion contains a questionName and an array of answers. Form is an object with a questions field as an array.

quizForm: Form = this.fb.group({
  questions: this.fb.array<FormQuestion>([]),
});

Now we can properly type our form, and it will validate all our fields. This is why we specified the Form type for our form and FormQuestion for our list of questions.

Setting up form

Now, I want to add an empty question to our form so we can render it by default.


export class QuizFormComponent {
  ...
  quizForm: Form = this.fb.group({
    questions: this.fb.array<FormQuestion>([this.generateQuestion()]),
  });

  generateQuestion(): FormQuestion {
    return this.fb.group({
      questionName: '',
      answers: this.fb.array<FormAnswer>([]),
    });
  }
}

We added a generateQuestion function, which creates an empty question with an empty list of answers. By calling this function inside our initial array, it will generate an empty question for us.

Rendering questions

Now, we need to add some markup.

<form [formGroup]="quizForm" (ngSubmit)="onSubmit()">
  <h1>Quiz form</h1>
  <div formArrayName="questions">
    @for (
      question of quizForm.controls.questions.controls;
      track questionIndex;
      let questionIndex = $index
    ) {
      <div class="question" [formGroupName]="questionIndex">
        <input
          type="text"
          formControlName="questionName"
          placeholder="Question name"
        />
      </div>
    }
  </div>
  <div><button type="submit">Submit</button></div>
</form>

Here, we created our reactive form with the name quizForm. Since we want to render a list of questions, we must use formArrayName="questions" on the parent container of the list of questions. In this way, the reactive form knows which part of the form we want to render. As you can see, we read a list of questions from quizForm.controls.questions.controls. After using formArrayName on the parent, we can read a specific questionName as a formControlName in each question.

first question

As you can see in the browser, our quiz form looks much better now. We rendered our first question with a question name input. Additionally, when we submit the form, you can see that we get our data in the correct format.

Adding Questions

The next thing we need to do is implement functionality to add new questions.

<div formArrayName="questions">
  ...
</div>
<div class="add-question">
  <button (click)="addQuestion()" type="button">Add Question</button>
</div>

We've added a button to add a question at the end of our form.

addQuestion(): void {
  this.quizForm.controls.questions.push(this.generateQuestion());
}

Since we already have a generateQuestion function, we're using it to push a new question to the array of questions.

add question

Now, we can click Add Question, and our new questions will appear.

add question result

We submit the form and receive a list of newly created questions.

Removing a question

To implement the removal of a question, we'll add a button next to each question that allows users to remove it.

<div class="question" [formGroupName]="questionIndex">
  <input
    type="text"
    formControlName="questionName"
    placeholder="Question name"
  />
  <span (click)="removeQuestion(questionIndex)" class="remove"></span>
  ...
</div>

After each input for the question, we'll add a span element with a button that allows users to remove the question by its index.

removeQuestion(questionIndex: number): void {
  this.quizForm.controls.questions.removeAt(questionIndex);
}

In the removeQuestion function, we remove an element from the array by its index (questionIndex).

remove question

Now, clicking on the remove button removes the corresponding question from the array.

Rendering answers

To render a list of answers inside each question, we'll iterate over the answers array for each question and display the answer inputs.

<span (click)="removeQuestion(questionIndex)" class="remove">✗</span>
<div formArrayName="answers" class="answers">
  <div>Answers</div>
  @for (
    question of quizForm.controls.questions.controls.at(questionIndex)
      ?.controls?.answers?.controls;
    track answerIndex;
    let answerIndex = $index
  ) {
    <div [formGroupName]="answerIndex" class="answer">
      <input type="text" placeholder="Answer" formControlName="text" />
    </div>
  }
</div>

The approach is similar. First, we determine for which question we need to render answers. Then, we apply formGroupName, which corresponds to the current answer index. This allows us to render the correct formControlName for each answer.

Adding an answer

Rendering answers is fine, but we can't really test it because we haven't implemented the creation of the answers yet.

<div formArrayName="answers" class="answers">
  ...
</div>
<button
  type="button"
  class="add-answer"
  (click)="addAnswer(questionIndex)"
>
  Add Answer
</button>

Here is a button inside each question to add an answer.

addAnswer(questionIndex: number): void {
  const newAnswer: FormAnswer = this.fb.group({
    text: '',
  });
  this.quizForm.controls.questions
    .at(questionIndex)
    ?.controls?.answers?.push(newAnswer);
}

In the addAnswer function, we create a new answer and push it to the list of answers in the specific question.

add answers

Now, we can click on the Add answer button to create answers for the specific question. Therefore, we have successfully rendered a list of answers for each question.

Removing an answer

The last feature that we need is the removal of the answer. We need the same logic as in removing the question.

<div [formGroupName]="answerIndex" class="answer">
  <input type="text" placeholder="Answer" formControlName="text" />
  <span
    (click)="removeAnswer(questionIndex, answerIndex)"
    class="remove"
    ></span
  >
</div>

Here, we created a click event on the span where we pass both the question index and the answer index to identify which answer to remove.

removeAnswer(questionIndex: number, answerIndex: number): void {
  this.quizForm.controls.questions
    .at(questionIndex)
    ?.controls?.answers?.removeAt(answerIndex);
}

We remove an answer from the specific question by using the question index.

remove answer

As you can see, it is now possible to remove answers.

result

Here is how our end object looks after submitting.

Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!

📚 Source code of what we've done
Don't miss a thing!
Follow me on Youtube, Twitter or Instagram.
Oleksandr Kocherhin
Oleksandr Kocherhin is a full-stack developer with a passion for learning and sharing knowledge on Monsterlessons Academy and on his YouTube channel. With around 15 years of programming experience and nearly 9 years of teaching, he has a deep understanding of both disciplines. He believes in learning by doing, a philosophy that is reflected in every course he teaches. He loves exploring new web and mobile technologies, and his courses are designed to give students an edge in the fast-moving tech industry.