Intermediate

Angular: Reactive Forms

Reactive forms — FormGroups, FormBuilder, validation, custom controls and dynamic forms.

A comprehensive course by Jim Cooper — Practical application: contact management with reactive forms.


Table of Contents

  1. Course Overview
  2. Reactive Forms Fundamentals
  3. FormGroups, FormBuilder, and Submission
  4. Working with Input Elements and Data Types
  5. Reactive Forms Validation
  6. Custom Controls and ControlValueAccessors
  7. Dynamically Adding Form Elements
  8. Reacting to Changes

1. Course Overview

This course covers Angular Reactive Forms in depth through a contact management application. Here are the main topics covered:

  • Creating and configuring FormControl, FormGroup, and FormArray
  • Working with all HTML element types (radio, select, checkbox, textarea, date, range)
  • Managing data types (numbers, dates, booleans)
  • Validating data with built-in validators and custom validators
  • Creating custom ControlValueAccessors for dates and custom components
  • Dynamically adding form elements with FormArray
  • Subscribing to value changes with valueChanges and RxJS operators
flowchart TD
    A[Angular Reactive Forms] --> B[Module 2\nFundamentals]
    A --> C[Module 3\nFormGroups & FormBuilder]
    A --> D[Module 4\nInput Types]
    A --> E[Module 5\nValidation]
    A --> F[Module 6\nControlValueAccessors]
    A --> G[Module 7\nDynamic FormArrays]
    A --> H[Module 8\nReacting to Changes]

2. Reactive Forms Fundamentals

Adding Reactive Forms to an Angular Project

To use reactive forms, you must import ReactiveFormsModule in the component (or in the module imports):

import { ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: './edit-contact.component.html',
})
export class EditContactComponent { }

Creating a FormControl

A FormControl represents an individual form field. It encapsulates the field’s value and validation state.

import { FormControl } from '@angular/forms';

// Simple creation
const firstName = new FormControl('');

// With typed initial value (non-nullable)
const firstName = new FormControl<string>('');

In the HTML template, a FormControl is bound using the formControl or formControlName directive:

<input [formControl]="firstName" placeholder="First Name" />

The Role of ControlValueAccessors

A ControlValueAccessor is the bridge between an HTML element and a FormControl. It serves two roles:

  1. Writing the value into the HTML element when the FormControl changes (writeValue)
  2. Reading the value from the HTML element when the user interacts (registerOnChange)

Angular provides default ControlValueAccessors for each input type:

flowchart LR
    FC[FormControl] <-->|ControlValueAccessor| HTML[HTML Element]
    HTML -->|onChange| FC
    FC -->|writeValue| HTML
Input TypeControlValueAccessor provided by Angular
input[type=text]DefaultValueAccessor (always returns a string)
input[type=number]NumberValueAccessor (returns a number)
input[type=range]RangeValueAccessor (returns a number)
input[type=checkbox]CheckboxControlValueAccessor (returns a boolean)
input[type=radio]RadioControlValueAccessor
selectSelectControlValueAccessor

Providing a Value to a FormControl: setValue and patchValue

// setValue: updates ALL fields (must match the form shape exactly)
this.contactForm.setValue(contact);

// patchValue: updates only specific fields
this.contactForm.patchValue({ firstName: 'Alice', lastName: 'Smith' });

Warning: setValue throws an error if the object passed does not exactly match the form structure. Use patchValue for partial updates.

Reading the Form Value

// getRawValue() returns all values, including disabled fields
const contact = this.contactForm.getRawValue();

// .value ignores disabled fields
const contact = this.contactForm.value;

3. FormGroups, FormBuilder, and Submission

Creating a FormGroup

A FormGroup groups multiple related FormControls. The FormGroup structure should reflect the data structure:

classDiagram
    class FormGroup {
        id: FormControl
        firstName: FormControl
        lastName: FormControl
        dateOfBirth: FormControl
        phone: FormGroup
        address: FormGroup
        notes: FormControl
    }
    class PhoneFormGroup {
        phoneNumber: FormControl
        phoneType: FormControl
    }
    class AddressFormGroup {
        streetAddress: FormControl
        city: FormControl
        state: FormControl
        postalCode: FormControl
        addressType: FormControl
    }
    FormGroup --> PhoneFormGroup : phone
    FormGroup --> AddressFormGroup : address

Using the FormBuilder

The FormBuilder simplifies form creation. It is injected via the constructor:

import { FormBuilder, ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [CommonModule, ReactiveFormsModule],
  templateUrl: './edit-contact.component.html',
})
export class EditContactComponent implements OnInit {
  contactForm = this.fb.nonNullable.group({
    id: '',
    firstName: '',
    lastName: '',
    dateOfBirth: <Date | null>null,
    favoritesRanking: <number | null>null,
    phone: this.fb.nonNullable.group({
      phoneNumber: '',
      phoneType: '',
    }),
    address: this.fb.nonNullable.group({
      streetAddress: '',
      city: '',
      state: '',
      postalCode: '',
      addressType: '',
    }),
  });

  constructor(
    private route: ActivatedRoute,
    private contactsService: ContactsService,
    private router: Router,
    private fb: FormBuilder
  ) { }

  ngOnInit() {
    const contactId = this.route.snapshot.params['id'];
    if (!contactId) return;

    this.contactsService.getContact(contactId).subscribe((contact) => {
      if (!contact) return;
      this.contactForm.setValue(contact);
    });
  }

  saveContact() {
    this.contactsService.saveContact(this.contactForm.getRawValue()).subscribe({
      next: () => this.router.navigate(['/contacts'])
    });
  }
}

fb.nonNullable.group(): guarantees that values cannot be null. Recommended for most cases.

Binding a FormGroup to the HTML Template

In the template, the [formGroup] directive binds the component and formGroupName is used for nested groups:

<form [formGroup]="contactForm" (ngSubmit)="saveContact()">
  <input formControlName="firstName" placeholder="First Name" />
  <input formControlName="lastName" placeholder="Last Name" />

  <!-- Nested FormGroup -->
  <div formGroupName="phone">
    <input formControlName="phoneNumber" placeholder="Phone" />
    <div class="radio">
      <input type="radio" formControlName="phoneType" value="mobile"> Mobile
      <input type="radio" formControlName="phoneType" value="work"> Work
      <input type="radio" formControlName="phoneType" value="other"> Other
    </div>
  </div>

  <div formGroupName="address">
    <input formControlName="streetAddress" placeholder="Address" />
    <input formControlName="city" placeholder="City" />
    <input formControlName="state" placeholder="State/Region" />
    <input formControlName="postalCode" placeholder="Zip/Postal Code" />
  </div>

  <button type="submit">Save</button>
</form>

Hidden FormControls

It is possible to have FormControls in the form model without binding them to an HTML element (useful for an entity’s id, for example). These fields are included in getRawValue() but not rendered in the template.


4. Working with Input Elements and Data Types

Radio Buttons

For radio buttons, all inputs point to the same formControlName. Each radio has a different value attribute. The FormControl receives the value of the selected option:

<input type="radio" formControlName="phoneType" value="mobile"> Mobile
<input type="radio" formControlName="phoneType" value="work"> Work
<input type="radio" formControlName="phoneType" value="other"> Other

Options can also be generated dynamically from the component:

// contact.model.ts
export const phoneTypeValues = [
  { title: 'Mobile', value: 'mobile' },
  { title: 'Work',   value: 'work'   },
  { title: 'Other',  value: 'other'  },
];
@for(phoneType of phoneTypes; track phoneType.value) {
  <input type="radio" formControlName="phoneType" [value]="phoneType.value">
  {{phoneType.title}}
}

Select Lists

A <select> binds to a FormControl via formControlName. The FormControl’s value corresponds to the selected option’s value:

<select formControlName="addressType">
  @for(addressType of addressTypes; track addressType.value) {
    <option [value]="addressType.value">{{addressType.title}}</option>
  }
</select>

Checkboxes

Checkboxes use the CheckboxControlValueAccessor. Their value is always a boolean (true/false):

// In the form model
contactForm = this.fb.nonNullable.group({
  personal: false,
  // ...
});
<input type="checkbox" formControlName="personal" /> Personal

Numeric Inputs

Problem: an <input type="text"> uses the DefaultValueAccessor which always returns a string, even if the FormControl is typed as number.

Solution: use <input type="number"> to activate the NumberValueAccessor:

<!-- ❌ Returns a string -->
<input formControlName="favoritesRanking" />

<!-- ✓ Returns a number via the NumberValueAccessor -->
<input type="number" formControlName="favoritesRanking" />

Range Inputs

The <input type="range"> uses the RangeValueAccessor which also returns a number:

<span>Favorites Ranking:</span>
<input formControlName="favoritesRanking" type="range" min="0" max="5" />
<span>{{contactForm.controls.favoritesRanking.value}}</span>

Textarea

A <textarea> works exactly like an <input type="text"> with reactive forms:

<textarea placeholder="Notes" rows="5" formControlName="notes"></textarea>

Dates

Problem: date inputs store the value as a string in the yyyy-MM-dd format. If you want to work with real Date objects, you need to handle the conversion.

Temporary solution using a date pipe on the value binding:

<input formControlName="dateOfBirth" type="date"
  [value]="contactForm.controls.dateOfBirth.value | date:'yyyy-MM-dd'"
  placeholder="Date of Birth" />

Limitation: This approach has timezone issues because dates are saved in UTC. The proper solution is to create a custom ControlValueAccessor (see Module 6).

flowchart LR
    A[JS Date Object] -->|writeValue\ntoISOString split T 0| B[input type=date\nstring yyyy-MM-dd]
    B -->|valueAsDate\nreturns Date| A

5. Reactive Forms Validation

Angular’s Built-in Validators

Angular provides these validators through the Validators class:

ValidatorDescription
Validators.requiredNon-empty value required
Validators.min(n)Numeric value ≥ n
Validators.max(n)Numeric value ≤ n
Validators.minLength(n)String length ≥ n
Validators.maxLength(n)String length ≤ n
Validators.emailValid email format
Validators.pattern(regex)Matches the regular expression
Validators.requiredTrueValue must be true (checkboxes)

Adding Validators to a FormControl

import { Validators } from '@angular/forms';

contactForm = this.fb.nonNullable.group({
  // Single validator
  firstName: ['', Validators.required],
  
  // Multiple validators (array)
  firstName: ['', [Validators.required, Validators.minLength(3)]],
  
  // FormGroup with validators on each field
  address: this.fb.nonNullable.group({
    streetAddress: ['', Validators.required],
    city: ['', Validators.required],
    state: ['', Validators.required],
    postalCode: ['', Validators.required],
    addressType: '',
  }),
});

Validation States of a FormControl

stateDiagram-v2
    [*] --> Pristine : Initialization
    Pristine --> Dirty : User types
    Pristine --> Touched : User leaves field without typing
    Dirty --> Touched : User leaves field
    
    state "valid / invalid" as VI
    Dirty --> VI
    Touched --> VI
PropertyDescription
validtrue if all validators pass
invalidtrue if at least one validator fails
touchedtrue if the field was focused then left
untouchedtrue if the field was never touched
dirtytrue if the value was modified
pristinetrue if the value has never changed
errorsObject with current errors (null if none)

Displaying Validation Messages

Best practice: use getter properties in the component to avoid repetition:

get firstName() {
  return this.contactForm.controls.firstName;
}

get notes() {
  return this.contactForm.controls.notes;
}

In the template, display messages based on the specific failing validator:

<input formControlName="firstName"
  [class.error]="firstName.invalid && firstName.touched"
  placeholder="First Name" />

@if(firstName.errors?.['required'] && firstName.touched) {
  <em>Please enter a First Name</em>
}
@if(firstName.errors?.['minlength'] && firstName.touched) {
  <em>First Name must be at least 3 characters</em>
}

errors object key: the key name corresponds to the failing validator. E.g.: required, minlength, maxlength, email, restrictedWords.

Validating an Entire FormGroup

When fields are grouped in a FormGroup, you can check the validity of the whole group rather than field by field:

<div formGroupName="address"
  [class.error]="contactForm.controls.address.invalid && contactForm.controls.address.dirty">
  <!-- address fields -->
</div>

@if(contactForm.controls.address.invalid && contactForm.controls.address.dirty) {
  <em>Incomplete Address</em>
}

Use dirty (the user has started typing) rather than touched for a better user experience at the group level.

Disabling the Submit Button

<button type="submit" class="primary" [disabled]="contactForm.invalid">Save</button>

Creating a Custom Validator

A validator is simply a function that takes an AbstractControl and returns a ValidationErrors object or null:

// validators/restricted-words.validator.ts
import { AbstractControl, ValidationErrors } from '@angular/forms';

export function restrictedWords(words: string[]) {
  return (control: AbstractControl): ValidationErrors | null => {
    const invalidWords = words
      .map(w => control.value.includes(w) ? w : null)
      .filter(w => w !== null);

    return invalidWords.length > 0
      ? { restrictedWords: invalidWords.join(', ') }
      : null;
  };
}

Key points:

  • The validator returns null when the value is valid
  • Returns an object with a key accessible via control.errors?.['restrictedWords']
  • To pass parameters, wrap the validator in an outer function (factory pattern)

Usage in the form model:

import { restrictedWords } from '../validators/restricted-words.validator';

notes: ['', restrictedWords(['foo', 'bar'])],

Display in the template:

<textarea formControlName="notes" [class.error]="notes.invalid"></textarea>

@if(notes.errors?.['restrictedWords']) {
  <em>Restricted words found: {{notes.errors?.['restrictedWords']}}</em>
}
flowchart LR
    A[AbstractControl] -->|value| B{restrictedWords\nvalidator}
    B -->|words found| C[ValidationErrors\n restrictedWords: 'foo, bar']
    B -->|no words| D[null\n= valid]

6. Custom Controls and ControlValueAccessors

The ControlValueAccessor Interface

Every ControlValueAccessor must implement these 3 methods:

MethodRole
writeValue(value)Updates the HTML element when the FormControl changes
registerOnChange(fn)Registers the callback called when the user modifies the value
registerOnTouched(fn)Registers the callback called when the element is touched
sequenceDiagram
    participant FC as FormControl
    participant CVA as ControlValueAccessor
    participant HTML as HTML Element

    FC->>CVA: writeValue(newValue)
    CVA->>HTML: Updates displayed value

    HTML->>CVA: input/change event
    CVA->>FC: onChange(newValue)
    FC->>FC: Updates internal value

Creating a Custom DateValueAccessor

To work correctly with Date objects in date inputs, we create a directive:

// date-value-accessor/date-value-accessor.directive.ts
import { Directive, ElementRef, HostListener, Provider, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

const DATE_VALUE_PROVIDER: Provider = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => DateValueAccessorDirective),
  multi: true,
};

@Directive({
  selector: 'input([type=date])[formControlName],input([type=date])[formControl],input([type=date])[ngModel]',
  providers: [DATE_VALUE_PROVIDER]
})
export class DateValueAccessorDirective implements ControlValueAccessor {

  constructor(private element: ElementRef) { }

  @HostListener('input', ['$event.target.valueAsDate'])
  private onChange!: Function;

  @HostListener('blur')
  private onTouched!: Function;

  registerOnChange(fn: Function) {
    this.onChange = (valueAsDate: Date) => { fn(valueAsDate); };
  }

  registerOnTouched(fn: Function) {
    this.onTouched = fn;
  }

  writeValue(newValue: any) {
    if (newValue instanceof Date) {
      this.element.nativeElement.value = newValue.toISOString().split('T')[0];
    }
  }
}

Key implementation points:

  • Selector: targets only input[type=date] elements bound to a FormControl
  • writeValue: converts the Date object to a yyyy-MM-dd string via toISOString().split('T')[0]
  • @HostListener('input', ['$event.target.valueAsDate']): captures the native value as a Date object via the DOM element’s valueAsDate property
  • NG_VALUE_ACCESSOR with multi: true: registers as a provider to replace the default accessor
  • forwardRef: necessary to reference the class before it is defined

Creating a Custom Input Component

To create a custom component (ProfileIconSelector) that can be bound to a FormControl:

Step 1: Register as NG_VALUE_ACCESSOR

import { Component } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Provider, forwardRef } from '@angular/core';

const PROFILE_ICON_VALUE_ACCESSOR: Provider = {
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => ProfileIconSelectorComponent),
  multi: true,
};

@Component({
  selector: 'con-profile-icon-selector',
  templateUrl: './profile-icon-selector.component.html',
  providers: [PROFILE_ICON_VALUE_ACCESSOR]
})
export class ProfileIconSelectorComponent implements ControlValueAccessor {
  
  selectedIcon = '';
  
  private onChange!: Function;
  private onTouched!: Function;

  writeValue(icon: string | null) {
    this.selectedIcon = icon ?? '';
  }

  registerOnChange(fn: Function) {
    this.onChange = fn;
  }

  registerOnTouched(fn: Function) {
    this.onTouched = fn;
  }

  selectIcon(icon: string) {
    this.selectedIcon = icon;
    this.onChange(icon);
    this.onTouched();
  }
}

Step 2: Use the component in the form

In the form model, add a FormControl for the icon:

contactForm = this.fb.nonNullable.group({
  icon: '',
  // ...other fields
});

In the template, use the component’s selector with formControlName:

<con-profile-icon-selector formControlName="icon" />

7. Dynamically Adding Form Elements

Understanding FormArrays

A FormArray is like an array of FormControls or FormGroups that can be modified dynamically:

classDiagram
    class FormArray {
        controls: AbstractControl[]
        push(control)
        removeAt(index)
        at(index)
    }
    class FormGroup_Phone {
        phoneNumber: FormControl
        phoneType: FormControl
        preferred: FormControl
    }
    FormArray "1" --> "*" FormGroup_Phone : contains
FeatureFormGroupFormArray
Control accessBy name (controls.firstName)By index (controls[0])
StructureFixedDynamic
Use caseStructured dataRepeatable lists

Adding a FormArray to the Form Model

contactForm = this.fb.nonNullable.group({
  // ...other fields
  phones: this.fb.array([this.createPhoneGroup()]),
});

createPhoneGroup() {
  return this.fb.nonNullable.group({
    phoneNumber: '',
    phoneType: '',
    preferred: false,
  });
}

addPhone() {
  this.contactForm.controls.phones.push(this.createPhoneGroup());
}

Initializing a FormArray from Data

When API data has multiple phone numbers, you need to create the corresponding FormGroups before calling setValue:

ngOnInit() {
  const contactId = this.route.snapshot.params['id'];
  if (!contactId) return;

  this.contactsService.getContact(contactId).subscribe((contact) => {
    if (!contact) return;

    // Add the additional FormGroups needed
    for (let i = 1; i < contact.phones.length; i++) {
      this.addPhone();
    }
    // Now setValue can map all the data
    this.contactForm.setValue(contact);
  });
}

Binding a FormArray to the HTML Template

<div formArrayName="phones">
  @for(phone of contactForm.controls.phones.controls; track phone; let i=$index) {
    <div [formGroupName]="i" class="flex-column">
      <div class="flex-group">
        <input formControlName="phoneNumber" placeholder="Phone" />
        <img src="/assets/plus-grey-blue.png" class="add" (click)="addPhone()" />
      </div>
      <div class="radio">
        @for(phoneType of phoneTypes; track phoneType.value) {
          <input type="radio" formControlName="phoneType" [value]="phoneType.value">
          {{phoneType.title}}
        }
      </div>
    </div>
  }
</div>

Key points:

  • formArrayName="phones": binds the container div to the FormArray
  • @for(...; let i=$index): uses the index to access the correct FormGroup
  • [formGroupName]="i": dynamically binds each repeated div to the FormGroup at index i
flowchart TD
    FA[FormArray: phones] --> FG0[FormGroup index 0\nphoneNumber, phoneType]
    FA --> FG1[FormGroup index 1\nphoneNumber, phoneType]
    FA --> FGN[FormGroup index N\n...]
    FG0 <-->|formGroupName 0| HTML0[div formGroupName=0]
    FG1 <-->|formGroupName 1| HTML1[div formGroupName=1]

8. Reacting to Changes

Subscribing to Value Changes

Every FormControl, FormGroup, and FormArray exposes a valueChanges observable you can listen to:

// Subscribe to changes on a specific FormControl
phoneGroup.controls.preferred.valueChanges.subscribe(value => {
  if (value) {
    phoneGroup.controls.phoneNumber.addValidators([Validators.required]);
  } else {
    phoneGroup.controls.phoneNumber.removeValidators([Validators.required]);
  }
  // IMPORTANT: tell Angular to re-evaluate validity
  phoneGroup.controls.phoneNumber.updateValueAndValidity();
});

updateValueAndValidity() is essential after dynamically adding/removing validators. Without this call, Angular does not know it needs to re-evaluate the control’s validity.

Dynamically Adding and Removing Validators

// Add a validator
control.addValidators([Validators.required]);

// Remove a validator
control.removeValidators([Validators.required]);

// Always call afterwards:
control.updateValueAndValidity();

Transforming Events with RxJS

Problem: when the user starts typing in the address, error messages appear immediately, which is frustrating.

Solution: use debounceTime to wait until the user has finished typing:

import { debounceTime, distinctUntilChanged } from 'rxjs';

subscribeToAddressChanges() {
  const addressGroup = this.contactForm.controls.address;

  // Subscription 1: remove validators immediately as soon as the user starts typing
  addressGroup.valueChanges
    .pipe(distinctUntilChanged(this.stringifyCompare))
    .subscribe(() => {
      for (const controlName in addressGroup.controls) {
        addressGroup.get(controlName)?.removeValidators([Validators.required]);
        addressGroup.get(controlName)?.updateValueAndValidity();
      }
    });

  // Subscription 2: re-add validators after 2 seconds of inactivity
  addressGroup.valueChanges
    .pipe(
      debounceTime(2000),
      distinctUntilChanged(this.stringifyCompare)
    )
    .subscribe(() => {
      for (const controlName in addressGroup.controls) {
        addressGroup.get(controlName)?.addValidators([Validators.required]);
        addressGroup.get(controlName)?.updateValueAndValidity();
      }
    });
}

stringifyCompare(a: any, b: any) {
  return JSON.stringify(a) === JSON.stringify(b);
}

RxJS transformation flow:

sequenceDiagram
    participant User
    participant VC as valueChanges Observable
    participant S1 as Subscription 1\n(immediate)
    participant S2 as Subscription 2\n(debounceTime 2s)
    participant NG as Angular Validation

    User->>VC: Starts typing
    VC->>S1: emits immediately
    S1->>NG: removeValidators\nupdateValueAndValidity\n(hides errors)

    User->>VC: Continues typing...
    Note over S2: debounceTime holds emissions

    User->>VC: Stops typing (2s elapsed)
    VC->>S2: emits after delay
    S2->>NG: addValidators\nupdateValueAndValidity\n(shows errors if invalid)

RxJS Operators Used

OperatorRole
debounceTime(ms)Waits N ms of inactivity before emitting the last event
distinctUntilChanged(comparator)Only emits if the value changed from the previous one

Final Form Model (Module 8 — complete)

Here is the complete component with all features:

import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ContactsService } from '../contacts/contacts.service';
import { phoneTypeValues, addressTypeValues } from '../contacts/contact.model';
import { restrictedWords } from '../validators/restricted-words.validator';
import { ProfileIconSelectorComponent } from '../profile-icon-selector/profile-icon-selector.component';
import { debounceTime, distinctUntilChanged } from 'rxjs';

@Component({
  imports: [CommonModule, ReactiveFormsModule, ProfileIconSelectorComponent],
  templateUrl: './edit-contact.component.html',
  styleUrls: ['./edit-contact.component.css']
})
export class EditContactComponent implements OnInit {
  phoneTypes = phoneTypeValues;
  addressTypes = addressTypeValues;

  contactForm = this.fb.nonNullable.group({
    id: '',
    icon: '',
    personal: false,
    firstName: ['', [Validators.required, Validators.minLength(3)]],
    lastName: '',
    dateOfBirth: <Date | null>null,
    favoritesRanking: <number | null>null,
    phones: this.fb.array([this.createPhoneGroup()]),
    address: this.fb.nonNullable.group({
      streetAddress: ['', Validators.required],
      city: ['', Validators.required],
      state: ['', Validators.required],
      postalCode: ['', Validators.required],
      addressType: '',
    }),
    notes: ['', restrictedWords(['foo', 'bar'])],
  });

  constructor(
    private route: ActivatedRoute,
    private contactsService: ContactsService,
    private router: Router,
    private fb: FormBuilder
  ) { }

  ngOnInit() {
    const contactId = this.route.snapshot.params['id'];
    if (!contactId) {
      this.subscribeToAddressChanges();
      return;
    }
    this.contactsService.getContact(contactId).subscribe((contact) => {
      if (!contact) return;
      for (let i = 1; i < contact.phones.length; i++) {
        this.addPhone();
      }
      this.contactForm.setValue(contact);
      this.subscribeToAddressChanges();
    });
  }

  subscribeToAddressChanges() {
    const addressGroup = this.contactForm.controls.address;
    addressGroup.valueChanges
      .pipe(distinctUntilChanged(this.stringifyCompare))
      .subscribe(() => {
        for (const controlName in addressGroup.controls) {
          addressGroup.get(controlName)?.removeValidators([Validators.required]);
          addressGroup.get(controlName)?.updateValueAndValidity();
        }
      });
    addressGroup.valueChanges
      .pipe(debounceTime(2000), distinctUntilChanged(this.stringifyCompare))
      .subscribe(() => {
        for (const controlName in addressGroup.controls) {
          addressGroup.get(controlName)?.addValidators([Validators.required]);
          addressGroup.get(controlName)?.updateValueAndValidity();
        }
      });
  }

  stringifyCompare(a: any, b: any) {
    return JSON.stringify(a) === JSON.stringify(b);
  }

  createPhoneGroup() {
    const phoneGroup = this.fb.nonNullable.group({
      phoneNumber: '',
      phoneType: '',
      preferred: false,
    });
    phoneGroup.controls.preferred.valueChanges
      .pipe(distinctUntilChanged(this.stringifyCompare))
      .subscribe(value => {
        if (value)
          phoneGroup.controls.phoneNumber.addValidators([Validators.required]);
        else
          phoneGroup.controls.phoneNumber.removeValidators([Validators.required]);
        phoneGroup.controls.phoneNumber.updateValueAndValidity();
      });
    return phoneGroup;
  }

  addPhone() {
    this.contactForm.controls.phones.push(this.createPhoneGroup());
  }

  get firstName() { return this.contactForm.controls.firstName; }
  get notes() { return this.contactForm.controls.notes; }

  saveContact() {
    this.contactsService.saveContact(this.contactForm.getRawValue()).subscribe({
      next: () => this.router.navigate(['/contacts'])
    });
  }
}

Summary: Reactive Forms Class Overview

classDiagram
    class AbstractControl {
        +value
        +valid: boolean
        +invalid: boolean
        +touched: boolean
        +dirty: boolean
        +errors: ValidationErrors
        +valueChanges: Observable
        +addValidators(validators)
        +removeValidators(validators)
        +updateValueAndValidity()
        +setValue(value)
        +patchValue(value)
    }
    class FormControl {
        +getRawValue()
    }
    class FormGroup {
        +controls
        +getRawValue()
    }
    class FormArray {
        +controls: AbstractControl[]
        +push(control)
        +removeAt(index)
        +at(index)
    }
    class FormBuilder {
        +group(config)
        +control(value, validators?)
        +array(controls)
        +nonNullable: NonNullableFormBuilder
    }

    AbstractControl <|-- FormControl
    AbstractControl <|-- FormGroup
    AbstractControl <|-- FormArray
    FormBuilder ..> FormControl : creates
    FormBuilder ..> FormGroup : creates
    FormBuilder ..> FormArray : creates

Summary: Template Directives

DirectiveUsage
[formGroup]="myForm"Binds the <form> element to the component’s FormGroup
formControlName="field"Binds an input to a FormControl by its name in the parent FormGroup
formGroupName="group"Binds a div/section to a nested FormGroup
formArrayName="array"Binds a div/section to a FormArray
[formGroupName]="i"Dynamically binds to a FormGroup inside a FormArray by index
(ngSubmit)="save()"Form submission event

Search Terms

angular · reactive · forms · frontend · development · custom · form · formcontrol · formarray · formgroup · template · validation · validators · value · binding · changes · controlvalueaccessors · data · dynamically · elements · formbuilder · html · input · inputs

Interested in this course?

Contact us to book it or get a custom training plan for your team.