A comprehensive course by Jim Cooper — Practical application: contact management with reactive forms.
Table of Contents
- Course Overview
- Reactive Forms Fundamentals
- FormGroups, FormBuilder, and Submission
- Working with Input Elements and Data Types
- Reactive Forms Validation
- Custom Controls and ControlValueAccessors
- Dynamically Adding Form Elements
- 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, andFormArray - 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
valueChangesand 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:
- Writing the value into the HTML element when the
FormControlchanges (writeValue) - 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 Type | ControlValueAccessor 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 |
select | SelectControlValueAccessor |
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:
setValuethrows an error if the object passed does not exactly match the form structure. UsepatchValuefor 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 benull. 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:
| Validator | Description |
|---|---|
Validators.required | Non-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.email | Valid email format |
Validators.pattern(regex) | Matches the regular expression |
Validators.requiredTrue | Value 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
| Property | Description |
|---|---|
valid | true if all validators pass |
invalid | true if at least one validator fails |
touched | true if the field was focused then left |
untouched | true if the field was never touched |
dirty | true if the value was modified |
pristine | true if the value has never changed |
errors | Object 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>
}
errorsobject key: the key name corresponds to the failing validator. E.g.:required,minlength,maxlength,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 thantouchedfor 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
nullwhen 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:
| Method | Role |
|---|---|
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 aFormControl writeValue: converts theDateobject to ayyyy-MM-ddstring viatoISOString().split('T')[0]@HostListener('input', ['$event.target.valueAsDate']): captures the native value as aDateobject via the DOM element’svalueAsDatepropertyNG_VALUE_ACCESSORwithmulti: true: registers as a provider to replace the default accessorforwardRef: 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
| Feature | FormGroup | FormArray |
|---|---|---|
| Control access | By name (controls.firstName) | By index (controls[0]) |
| Structure | Fixed | Dynamic |
| Use case | Structured data | Repeatable 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 theFormArray@for(...; let i=$index): uses the index to access the correctFormGroup[formGroupName]="i": dynamically binds each repeated div to theFormGroupat indexi
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
| Operator | Role |
|---|---|
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
| Directive | Usage |
|---|---|
[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