DEV Community

Atilla Baspinar
Atilla Baspinar

Posted on

Reactive Forms

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(''),
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

To bind a single control outside of a [formGroup], use [formControl] directly:

<input [formControl]="form.controls.email" />
Enter fullscreen mode Exit fullscreen mode

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); // '...'
}
Enter fullscreen mode Exit fullscreen mode

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)],
  }),
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Use it alongside built-in validators:

form = new FormGroup({
  password: new FormControl('', {
    validators: [Validators.required, mustContainQuestionMark],
  }),
});
Enter fullscreen mode Exit fullscreen mode
@if (form.controls.password.errors?.['missingQuestionMark']) {
  <p class="error">Password must contain a question mark.</p>
}
Enter fullscreen mode Exit fullscreen mode

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))
        )
      ],
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode
<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>
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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: '' });
Enter fullscreen mode Exit fullscreen mode
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(''),
  }),
});
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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],
});
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)