Angular Form Arrays
Introduction
When building forms in Angular, we often need to work with dynamic collections of form controls. For example, you might need to let users add multiple phone numbers, addresses, or items to an order. This is where FormArray comes into play.
FormArray is a powerful feature of Angular's reactive forms that allows you to manage an array of form controls or form groups. It lets you dynamically add, remove, and manipulate form elements, making it ideal for handling variable-length form inputs.
In this guide, we'll explore how to use FormArray effectively, from basic setup to more advanced use cases.
Prerequisites
Before diving into FormArray, you should be familiar with:
- Basic Angular concepts
- Reactive Forms in Angular
- FormGroup and FormControl
Getting Started with FormArray
Basic Setup
First, let's set up a simple FormArray example. We'll create a form that allows users to add and remove skills:
import { Component, OnInit } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-skills-form',
templateUrl: './skills-form.component.html'
})
export class SkillsFormComponent implements OnInit {
skillsForm: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.skillsForm = this.fb.group({
name: ['', Validators.required],
skills: this.fb.array([])
});
}
// Getter for easier access to the FormArray
get skills(): FormArray {
return this.skillsForm.get('skills') as FormArray;
}
// Method to add a new skill control
addSkill() {
this.skills.push(this.fb.control('', Validators.required));
}
// Method to remove a skill control
removeSkill(index: number) {
this.skills.removeAt(index);
}
onSubmit() {
console.log('Form Value:', this.skillsForm.value);
}
}
Now let's create the template to display and interact with this form:
<form [formGroup]="skillsForm" (ngSubmit)="onSubmit()">
<div>
<label for="name">Name:</label>
<input id="name" type="text" formControlName="name">
</div>
<div>
<h3>Skills</h3>
<button type="button" (click)="addSkill()">Add Skill</button>
<div formArrayName="skills">
<div *ngFor="let skill of skills.controls; let i = index">
<label>
Skill {{i + 1}}:
<input [formControlName]="i" type="text">
</label>
<button type="button" (click)="removeSkill(i)">Remove</button>
</div>
</div>
</div>
<button type="submit" [disabled]="!skillsForm.valid">Submit</button>
</form>
Key Points to Understand
- Creating the FormArray: We use
this.fb.array([])
to initialize an empty FormArray. - Accessing the FormArray: We create a getter method to easily access our FormArray.
- Adding controls: Use
push()
to add new FormControls to the array. - Removing controls: Use
removeAt(index)
to remove a specific control. - Template binding: The
formArrayName
directive connects the HTML to our FormArray, and each control uses the index as its FormControlName.
Nested Form Groups in a FormArray
Often, you'll need more complex structures where each item in your array is not just a single control but a group of related controls.
Example: Contact Form with Multiple Addresses
import { Component, OnInit } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-contact-form',
templateUrl: './contact-form.component.html'
})
export class ContactFormComponent implements OnInit {
contactForm: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.contactForm = this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
addresses: this.fb.array([])
});
// Add one address by default
this.addAddress();
}
get addresses(): FormArray {
return this.contactForm.get('addresses') as FormArray;
}
addAddress() {
const addressForm = this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
state: ['', Validators.required],
zipCode: ['', [Validators.required, Validators.pattern('^[0-9]{5}(?:-[0-9]{4})?$')]]
});
this.addresses.push(addressForm);
}
removeAddress(index: number) {
this.addresses.removeAt(index);
}
onSubmit() {
console.log('Contact Form Value:', this.contactForm.value);
}
}
Here's the corresponding template:
<form [formGroup]="contactForm" (ngSubmit)="onSubmit()">
<div>
<label for="firstName">First Name:</label>
<input id="firstName" type="text" formControlName="firstName">
</div>
<div>
<label for="lastName">Last Name:</label>
<input id="lastName" type="text" formControlName="lastName">
</div>
<div formArrayName="addresses">
<h3>Addresses</h3>
<button type="button" (click)="addAddress()">Add Address</button>
<div *ngFor="let address of addresses.controls; let i = index">
<div [formGroupName]="i" class="address-container">
<h4>Address {{i + 1}}</h4>
<div>
<label for="street-{{i}}">Street:</label>
<input id="street-{{i}}" type="text" formControlName="street">
</div>
<div>
<label for="city-{{i}}">City:</label>
<input id="city-{{i}}" type="text" formControlName="city">
</div>
<div>
<label for="state-{{i}}">State:</label>
<input id="state-{{i}}" type="text" formControlName="state">
</div>
<div>
<label for="zipCode-{{i}}">Zip Code:</label>
<input id="zipCode-{{i}}" type="text" formControlName="zipCode">
</div>
<button type="button" (click)="removeAddress(i)">Remove This Address</button>
</div>
</div>
</div>
<button type="submit" [disabled]="!contactForm.valid">Submit</button>
</form>
In this example:
- We create a FormArray that contains FormGroups (each representing an address)
- Each FormGroup has multiple FormControls (street, city, state, zipCode)
- We use
[formGroupName]="i"
to bind each address group in the template
Practical Example: Dynamic Order Form
Let's create a more realistic example - an order form where users can add multiple items with quantity and price:
import { Component, OnInit } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-order-form',
templateUrl: './order-form.component.html'
})
export class OrderFormComponent implements OnInit {
orderForm: FormGroup;
availableProducts = [
{ id: 1, name: 'Product A', unitPrice: 10.99 },
{ id: 2, name: 'Product B', unitPrice: 5.99 },
{ id: 3, name: 'Product C', unitPrice: 15.99 }
];
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.orderForm = this.fb.group({
customerName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
items: this.fb.array([])
});
// Add one item by default
this.addItem();
}
get items(): FormArray {
return this.orderForm.get('items') as FormArray;
}
addItem() {
const itemForm = this.fb.group({
productId: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
unitPrice: [{value: 0, disabled: true}],
subtotal: [{value: 0, disabled: true}]
});
// Set up value change subscriptions
itemForm.get('productId').valueChanges.subscribe(productId => {
const product = this.availableProducts.find(p => p.id === Number(productId));
if (product) {
itemForm.patchValue({ unitPrice: product.unitPrice });
this.updateSubtotal(itemForm);
}
});
itemForm.get('quantity').valueChanges.subscribe(() => {
this.updateSubtotal(itemForm);
});
this.items.push(itemForm);
}
removeItem(index: number) {
this.items.removeAt(index);
}
updateSubtotal(itemForm: FormGroup) {
const quantity = itemForm.get('quantity').value || 0;
const unitPrice = itemForm.get('unitPrice').value || 0;
const subtotal = quantity * unitPrice;
itemForm.patchValue({ subtotal: subtotal.toFixed(2) });
}
get orderTotal() {
return this.items.controls
.map(item => Number((item as FormGroup).get('subtotal').value) || 0)
.reduce((prev, curr) => prev + curr, 0)
.toFixed(2);
}
onSubmit() {
// Get the raw form values (including disabled controls)
const formValue = this.orderForm.getRawValue();
console.log('Order Form Value:', formValue);
}
}
And the template:
<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
<div>
<label for="customerName">Customer Name:</label>
<input id="customerName" type="text" formControlName="customerName">
</div>
<div>
<label for="email">Email:</label>
<input id="email" type="email" formControlName="email">
</div>
<div formArrayName="items">
<h3>Order Items</h3>
<button type="button" (click)="addItem()">Add Item</button>
<div *ngFor="let item of items.controls; let i = index">
<div [formGroupName]="i" class="item-container">
<h4>Item {{i + 1}}</h4>
<div>
<label for="product-{{i}}">Product:</label>
<select id="product-{{i}}" formControlName="productId">
<option value="">-- Select a product --</option>
<option *ngFor="let product of availableProducts" [value]="product.id">
{{product.name}} (${{product.unitPrice}})
</option>
</select>
</div>
<div>
<label for="quantity-{{i}}">Quantity:</label>
<input id="quantity-{{i}}" type="number" min="1" formControlName="quantity">
</div>
<div>
<label for="unitPrice-{{i}}">Unit Price:</label>
<input id="unitPrice-{{i}}" type="text" formControlName="unitPrice" readonly>
</div>
<div>
<label for="subtotal-{{i}}">Subtotal:</label>
<input id="subtotal-{{i}}" type="text" formControlName="subtotal" readonly>
</div>
<button type="button" (click)="removeItem(i)">Remove Item</button>
</div>
</div>
</div>
<div class="order-total">
<h3>Order Total: ${{orderTotal}}</h3>
</div>
<button type="submit" [disabled]="!orderForm.valid">Place Order</button>
</form>
This example demonstrates several important concepts:
- Dynamic calculations within FormArrays
- Interdependent form controls that update based on each other's values
- Value change subscriptions to react to user input
- Disabled form controls for display values
- Aggregate calculations across all items in the array
Form Validation with FormArrays
FormArrays support validation just like other form components. You can:
- Validate individual controls within the array
- Validate the entire array (e.g., require at least one item)
- Access validation status of the array and its children
Here's how to add a validator to ensure your FormArray has at least one item:
import { Component } from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators, ValidatorFn, AbstractControl } from '@angular/forms';
// Custom validator function
function minArrayLength(min: number): ValidatorFn {
return (control: AbstractControl): {[key: string]: any} | null => {
if (control instanceof FormArray) {
return control.controls.length >= min ? null : { 'minArrayLength': {value: control.controls.length} };
}
return null;
};
}
@Component({
selector: 'app-validated-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div formArrayName="items">
<button type="button" (click)="addItem()">Add Item</button>
<div *ngFor="let item of items.controls; let i = index">
<input [formControlName]="i" placeholder="Enter item">
<button type="button" (click)="removeItem(i)">X</button>
</div>
<div *ngIf="items.hasError('minArrayLength')" class="error">
Please add at least one item
</div>
</div>
<button type="submit" [disabled]="!form.valid">Submit</button>
</form>
`
})
export class ValidatedFormComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
items: this.fb.array([], minArrayLength(1))
});
}
get items() {
return this.form.get('items') as FormArray;
}
addItem() {
this.items.push(this.fb.control('', Validators.required));
}
removeItem(index: number) {
this.items.removeAt(index);
}
onSubmit() {
console.log(this.form.value);
}
}
Best Practices for Working with FormArrays
- Create getter methods for FormArrays to simplify access in both component and template
- Use FormBuilder to create FormArrays more concisely
- Add descriptive validation messages for array-specific validation errors
- Consider performance when working with large arrays:
- Limit the number of items users can add
- Use trackBy with ngFor to improve rendering performance
- Maintain clear separation between form structure and data model
- Implement proper error handling for form array operations
Common Challenges and Solutions
Challenge: Adding Pre-Populated Items
Sometimes you need to initialize your FormArray with existing values:
initializeItems(existingItems: any[]) {
const itemFormGroups = existingItems.map(item => {
return this.fb.group({
id: [item.id],
name: [item.name, Validators.required],
description: [item.description]
});
});
return this.fb.array(itemFormGroups);
}
ngOnInit() {
// Sample data (could come from an API)
const existingItems = [
{ id: 1, name: 'Item 1', description: 'Description 1' },
{ id: 2, name: 'Item 2', description: 'Description 2' }
];
this.form = this.fb.group({
title: ['My List', Validators.required],
items: this.initializeItems(existingItems)
});
}
Challenge: Reorganizing Array Items
You might need to let users reorder items in your FormArray:
moveItemUp(index: number) {
if (index <= 0) return;
const items = this.items;
const itemToMove = items.at(index);
const newIndex = index - 1;
// Remove the item
items.removeAt(index);
// Insert it at the new position
items.insert(newIndex, itemToMove);
}
moveItemDown(index: number) {
if (index >= this.items.length - 1) return;
const items = this.items;
const itemToMove = items.at(index);
const newIndex = index + 1;
items.removeAt(index);
items.insert(newIndex, itemToMove);
}
Summary
FormArrays are a powerful feature of Angular's reactive forms that provide flexibility for managing collections of form controls dynamically. In this guide, we've covered:
- How to create and initialize FormArrays
- Adding and removing form controls dynamically
- Working with nested FormGroups within FormArrays
- Building practical examples like order forms
- Implementing validation for FormArrays
- Best practices and common challenges
With FormArrays, you can create dynamic, user-friendly forms that adapt to varying amounts of data, making them perfect for scenarios like:
- Contact forms with multiple addresses
- Order forms with multiple items
- User profiles with multiple skills or experiences
- Survey forms with variable numbers of questions
Additional Resources
- Angular Official Documentation on FormArray
- Angular Reactive Forms Guide
- FormArray Validation Strategies
Exercises
-
Create a survey form that allows administrators to add multiple questions, with each question having multiple choice options that can also be added dynamically.
-
Build a resume builder form that lets users add multiple sections for education, work experience, skills, and projects - each with their own nested form controls.
-
Implement a shopping cart form that calculates totals, applies discounts, and allows quantity adjustments with FormArrays.
-
Challenge: Create a nested FormArray where each item contains another FormArray (e.g., a meal planner with days, and each day has multiple meals).
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)