Reactive forms define the form structure and validation entirely in the component class using FormGroup and FormControl. The template only binds to these objects — no ngModel, no template variables for validation state.
Import ReactiveFormsModule in the component's imports array.
1. Setting up a reactive form
Define the form as a class field using FormGroup and FormControl:
import { Component } from '@angular/core';
import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
...
})
export class SignUpComponent {
form = new FormGroup({
email: new FormControl(''), // initial value
password: new FormControl(''),
});
}
Use [formGroup] on the <form> element and formControlName on each input:
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" />
<input formControlName="password" type="password" />
<button type="submit">Submit</button>
</form>
To bind a single control outside of a [formGroup], use [formControl] directly:
<input [formControl]="form.controls.email" />
No form argument is needed in the submit handler — this.form is always available in the class:
onSubmit() {
console.log(this.form.value); // { email: '...', password: '...' }
console.log(this.form.controls.email.value); // '...'
}
2. Built-in validators
Pass validators in the FormControl options object using the Validators class from @angular/forms:
import { FormGroup, FormControl, Validators } from '@angular/forms';
form = new FormGroup({
email: new FormControl('', {
validators: [Validators.required, Validators.email],
}),
password: new FormControl('', {
validators: [Validators.required, Validators.minLength(8)],
}),
});
Common built-in validators: required, email, minLength(n), maxLength(n), min(n), max(n), pattern(regex).
Displaying errors in the template
Use a getter to keep the template readable:
get emailInvalid() {
const ctrl = this.form.controls.email;
return ctrl.touched && ctrl.invalid;
}
<input formControlName="email" />
@if (emailInvalid) {
<p class="error">Enter a valid email address.</p>
}
@if (form.controls.password.errors?.['minlength']) {
<p class="error">Password must be at least 8 characters.</p>
}
<button type="submit" [disabled]="form.invalid">Submit</button>
3. Custom validators
A custom validator is a plain function that receives an AbstractControl and returns null if valid, or a ValidationErrors object if invalid. The object key becomes the error name.
import { AbstractControl, ValidationErrors } from '@angular/forms';
function mustContainQuestionMark(control: AbstractControl): ValidationErrors | null {
if (control.value?.includes('?')) {
return null; // valid
}
return { missingQuestionMark: true }; // invalid
}
Use it alongside built-in validators:
form = new FormGroup({
password: new FormControl('', {
validators: [Validators.required, mustContainQuestionMark],
}),
});
@if (form.controls.password.errors?.['missingQuestionMark']) {
<p class="error">Password must contain a question mark.</p>
}
4. Async validators
Async validators return an Observable<ValidationErrors | null> instead of a synchronous value. Angular sets the control's status to 'PENDING' while waiting and updates validity once the Observable emits.
A typical use case is checking whether an email is already registered:
import { Component, inject } from '@angular/core';
import { AbstractControl, ValidationErrors, FormGroup, FormControl, Validators } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs';
import { AuthService } from './auth.service';
@Component({ ... })
export class SignUpComponent {
private authService = inject(AuthService);
form = new FormGroup({
email: new FormControl('', {
validators: [Validators.required, Validators.email],
asyncValidators: [(control: AbstractControl): Observable<ValidationErrors | null> =>
timer(400).pipe(
switchMap(() => this.authService.isEmailTaken(control.value)),
map(taken => taken ? { emailTaken: true } : null),
catchError(() => of(null))
)
],
}),
});
}
<input formControlName="email" />
@if (form.controls.email.status === 'PENDING') {
<span>Checking availability…</span>
}
@if (form.controls.email.errors?.['emailTaken']) {
<p class="error">This email is already registered.</p>
}
How the debouncing works: timer(400) delays the API call by 400 ms. If the user types again before 400 ms elapse, Angular triggers a new validation cycle — unsubscribing from the previous Observable (which cancels the timer) and starting a fresh one. This naturally debounces the API call without any extra operator.
catchError(() => of(null)) ensures a network error does not permanently block submission — the field is treated as valid and the user can continue.
5. Reacting to value changes
form.valueChanges is an Observable that emits the full form value whenever any control changes. Subscribe to it in ngOnInit — the FormGroup is defined as a class field so it is ready before ngOnInit runs.
Use takeUntilDestroyed to clean up the subscription automatically when the component is destroyed:
import { Component, inject, OnInit } from '@angular/core';
import { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged } from 'rxjs';
@Component({ ... })
export class SignUpComponent implements OnInit {
private destroyRef = inject(DestroyRef);
form = new FormGroup({
email: new FormControl(''),
password: new FormControl(''),
});
ngOnInit() {
// whole form
this.form.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
console.log(value); // { email: '...', password: '...' }
});
// single control
this.form.controls.email.valueChanges.pipe(
debounceTime(300),
takeUntilDestroyed(this.destroyRef)
).subscribe(email => {
console.log('email changed:', email);
});
}
}
distinctUntilChanged() prevents re-processing when the emitted value is the same as the previous one — useful when you are triggering a search or API call.
6. Setting values programmatically
Use these methods to pre-fill a form (e.g. loading a user profile for editing) or to reset it after submission.
// setValue — must provide a value for every control; throws if any are missing
this.form.setValue({ email: 'user@example.com', password: 'secret' });
// patchValue — updates only the controls you specify; ignores the rest
this.form.patchValue({ email: 'user@example.com' }); // password is unchanged
// set a single control
this.form.controls.email.setValue('user@example.com');
// reset — clears values and resets state to pristine and untouched
this.form.reset();
// reset with specific initial values
this.form.reset({ email: 'user@example.com', password: '' });
| Method | Requires all controls | Resets state |
|---|---|---|
setValue(obj) |
Yes — throws if any key is missing | No |
patchValue(obj) |
No — partial update is fine | No |
reset(obj?) |
No | Yes — pristine, untouched |
Prefer patchValue when pre-filling from an API response that may not include every field. Use reset() after a successful form submission to clear the form and its validation state.
7. Nested FormGroup
Nest a FormGroup inside another FormGroup to organise related fields. In the template, use formGroupName to scope the inner group.
form = new FormGroup({
name: new FormControl('', Validators.required),
address: new FormGroup({
street: new FormControl(''),
postcode: new FormControl(''),
}),
});
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Full name" />
<fieldset formGroupName="address">
<input formControlName="street" placeholder="Street" />
<input formControlName="postcode" placeholder="Postcode" />
</fieldset>
<button type="submit">Submit</button>
</form>
form.value mirrors the nesting: { name: '...', address: { street: '...', postcode: '...' } }. Access a nested control with form.controls.address.controls.street.
8. FormArray
FormArray holds a dynamic list of controls whose length is not known at design time — for example, a list of email addresses the user can add or remove.
import { Component } from '@angular/core';
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
...
})
export class ContactComponent {
form = new FormGroup({
emails: new FormArray([new FormControl('', Validators.email)]),
});
get emails() {
return this.form.controls.emails;
}
addEmail() {
this.emails.push(new FormControl('', Validators.email));
}
removeEmail(index: number) {
this.emails.removeAt(index);
}
}
Use formArrayName in the template and bind each control by its index with [formControlName]="$index":
<form [formGroup]="form">
<div formArrayName="emails">
@for (ctrl of emails.controls; track $index) {
<div>
<input [formControlName]="$index" type="email" placeholder="Email" />
<button type="button" (click)="removeEmail($index)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addEmail()">Add email</button>
</form>
Key FormArray methods: push(control), removeAt(index), at(index), length.
9. FormGroup validators
A validator registered on the FormGroup receives the entire group as its AbstractControl, allowing comparison across fields. Register it in the second argument to FormGroup:
function passwordsMatch(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}
form = new FormGroup({
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
confirmPassword: new FormControl('', Validators.required),
}, {
validators: [passwordsMatch],
});
The error lands on form.errors, not on any individual control:
<input formControlName="confirmPassword" type="password" placeholder="Confirm password" />
@if (form.errors?.['passwordMismatch'] && form.controls.confirmPassword.touched) {
<p class="error">Passwords do not match.</p>
}
Angular also adds aggregate state classes to the <form> element — ng-valid, ng-dirty, ng-touched, ng-pending, and ng-submitted — each reflecting the combined state across all controls.
Top comments (0)