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?
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.
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.
Now, we can click Add Question
, and our new questions will appear.
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
).
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.
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.
As you can see, it is now possible to remove answers.
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