Skip to main content

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:

typescript
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:

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

  1. Creating the FormArray: We use this.fb.array([]) to initialize an empty FormArray.
  2. Accessing the FormArray: We create a getter method to easily access our FormArray.
  3. Adding controls: Use push() to add new FormControls to the array.
  4. Removing controls: Use removeAt(index) to remove a specific control.
  5. 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

typescript
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:

html
<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:

  1. We create a FormArray that contains FormGroups (each representing an address)
  2. Each FormGroup has multiple FormControls (street, city, state, zipCode)
  3. 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:

typescript
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:

html
<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:

  1. Dynamic calculations within FormArrays
  2. Interdependent form controls that update based on each other's values
  3. Value change subscriptions to react to user input
  4. Disabled form controls for display values
  5. Aggregate calculations across all items in the array

Form Validation with FormArrays

FormArrays support validation just like other form components. You can:

  1. Validate individual controls within the array
  2. Validate the entire array (e.g., require at least one item)
  3. 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:

typescript
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

  1. Create getter methods for FormArrays to simplify access in both component and template
  2. Use FormBuilder to create FormArrays more concisely
  3. Add descriptive validation messages for array-specific validation errors
  4. Consider performance when working with large arrays:
    • Limit the number of items users can add
    • Use trackBy with ngFor to improve rendering performance
  5. Maintain clear separation between form structure and data model
  6. 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:

typescript
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:

typescript
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

Exercises

  1. Create a survey form that allows administrators to add multiple questions, with each question having multiple choice options that can also be added dynamically.

  2. 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.

  3. Implement a shopping cart form that calculates totals, applies discounts, and allows quantity adjustments with FormArrays.

  4. 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! :)