DEV Community

Cover image for RxJS in Angular — Chapter 8 | RxJS + Angular Reactive Forms — Live Validation āĻ“ Dynamic Forms
Jack Pritom Soren
Jack Pritom Soren

Posted on

RxJS in Angular — Chapter 8 | RxJS + Angular Reactive Forms — Live Validation āĻ“ Dynamic Forms

"RxJS + Angular Reactive Forms — Live Validation & Dynamic Forms"


👋 Welcome to Chapter 8!

Forms are EVERYWHERE in web apps — login, registration, checkout, search, settings.

Angular's Reactive Forms are powered by RxJS under the hood. Every FormControl has streams you can subscribe to — valueChanges, statusChanges. This unlocks incredibly powerful form behavior that would be painfully difficult to build any other way.

Today we build real, production-quality forms with RxJS superpowers!


đŸŽ›ī¸ The Reactive Forms Quick Setup

First, let's make sure we have the right setup:

app.module.ts

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

@NgModule({
  imports: [ReactiveFormsModule]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

💡 The Key Streams on Every FormControl

Every FormControl, FormGroup, and FormArray has two key Observable streams:

valueChanges — emits every time the value changes
statusChanges — emits every time the validation status changes ('VALID', 'INVALID', 'PENDING', 'DISABLED')

const emailControl = new FormControl('');

// Watch the value
emailControl.valueChanges.subscribe(value => {
  console.log('Email is now:', value);
});

// Watch the validation status
emailControl.statusChanges.subscribe(status => {
  console.log('Status:', status); // 'VALID', 'INVALID', 'PENDING', 'DISABLED'
});
Enter fullscreen mode Exit fullscreen mode

🔍 Real Example 1 — Live Username Availability Check

This is extremely common: as the user types a username, check if it's available.

// register.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup, Validators, AbstractControl } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';

@Component({
  selector: 'app-register',
  template: `
    <form [formGroup]="registerForm" (ngSubmit)="onSubmit()">

      <div class="field">
        <label>Username</label>
        <input formControlName="username" placeholder="Choose a username" />

        <!-- Checking availability -->
        <span *ngIf="isCheckingUsername" class="checking">
          Checking availability... âŗ
        </span>

        <!-- Available -->
        <span *ngIf="usernameAvailable === true" class="available">
          ✅ Username is available!
        </span>

        <!-- Taken -->
        <span *ngIf="usernameAvailable === false" class="taken">
          ❌ Username is already taken
        </span>

        <!-- Validation errors -->
        <span *ngIf="registerForm.get('username')?.errors?.['required'] &&
                     registerForm.get('username')?.touched">
          Username is required
        </span>
        <span *ngIf="registerForm.get('username')?.errors?.['minlength']">
          At least 3 characters required
        </span>
      </div>

      <div class="field">
        <label>Email</label>
        <input formControlName="email" type="email" placeholder="Enter email" />
        <span *ngIf="registerForm.get('email')?.errors?.['email'] &&
                     registerForm.get('email')?.touched">
          Invalid email address
        </span>
      </div>

      <div class="field">
        <label>Password</label>
        <input formControlName="password" type="password" placeholder="Password" />
        <div class="password-strength" [class]="passwordStrength">
          {{ passwordStrengthLabel }}
        </div>
      </div>

      <div class="field">
        <label>Confirm Password</label>
        <input formControlName="confirmPassword" type="password" />
        <span *ngIf="registerForm.errors?.['passwordMismatch'] &&
                     registerForm.get('confirmPassword')?.touched">
          Passwords do not match
        </span>
      </div>

      <button type="submit" [disabled]="registerForm.invalid || isCheckingUsername">
        Create Account
      </button>

    </form>
  `
})
export class RegisterComponent implements OnInit, OnDestroy {

  registerForm!: FormGroup;
  isCheckingUsername = false;
  usernameAvailable: boolean | null = null;
  passwordStrength = 'weak';
  passwordStrengthLabel = '';

  private destroy$ = new Subject<void>();

  constructor(
    private fb: FormBuilder,
    private authService: AuthService
  ) {}

  ngOnInit(): void {
    this.registerForm = this.fb.group({
      username: ['', [Validators.required, Validators.minLength(3)]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required]
    }, {
      validators: this.passwordMatchValidator
    });

    // --- RxJS Magic: Username availability check ---
    this.registerForm.get('username')!.valueChanges
      .pipe(
        debounceTime(500),                   // Wait 500ms after typing
        distinctUntilChanged(),              // Don't check if same value
        takeUntil(this.destroy$)
      )
      .subscribe(username => {
        if (username && username.length >= 3) {
          this.isCheckingUsername = true;
          this.usernameAvailable = null;

          this.authService.checkUsername(username)
            .pipe(takeUntil(this.destroy$))
            .subscribe({
              next: (isAvailable) => {
                this.usernameAvailable = isAvailable;
                this.isCheckingUsername = false;
              },
              error: () => {
                this.isCheckingUsername = false;
              }
            });
        } else {
          this.usernameAvailable = null;
        }
      });

    // --- RxJS Magic: Password strength meter ---
    this.registerForm.get('password')!.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(password => {
        this.updatePasswordStrength(password || '');
      });
  }

  private updatePasswordStrength(password: string): void {
    let score = 0;
    if (password.length >= 8) score++;
    if (/[A-Z]/.test(password)) score++;
    if (/[0-9]/.test(password)) score++;
    if (/[^A-Za-z0-9]/.test(password)) score++;

    if (score <= 1) {
      this.passwordStrength = 'weak';
      this.passwordStrengthLabel = '🔴 Weak';
    } else if (score === 2) {
      this.passwordStrength = 'fair';
      this.passwordStrengthLabel = '🟡 Fair';
    } else if (score === 3) {
      this.passwordStrength = 'good';
      this.passwordStrengthLabel = 'đŸŸĸ Good';
    } else {
      this.passwordStrength = 'strong';
      this.passwordStrengthLabel = 'đŸ’Ē Strong';
    }
  }

  private passwordMatchValidator(group: AbstractControl) {
    const password = group.get('password')?.value;
    const confirm = group.get('confirmPassword')?.value;
    return password === confirm ? null : { passwordMismatch: true };
  }

  onSubmit(): void {
    if (this.registerForm.valid && this.usernameAvailable) {
      console.log('Register:', this.registerForm.value);
    }
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

đŸ™ī¸ Real Example 2 — Dependent Dropdowns (Country → City)

When selecting a country should dynamically load cities for that country:

@Component({
  template: `
    <form [formGroup]="addressForm">

      <select formControlName="country">
        <option value="">Select Country</option>
        <option *ngFor="let c of countries" [value]="c.code">
          {{ c.name }}
        </option>
      </select>

      <select formControlName="city">
        <option value="">
          {{ isLoadingCities ? 'Loading cities...' : 'Select City' }}
        </option>
        <option *ngFor="let city of cities" [value]="city.id">
          {{ city.name }}
        </option>
      </select>

    </form>
  `
})
export class AddressFormComponent implements OnInit, OnDestroy {

  addressForm = this.fb.group({
    country: [''],
    city: [{ value: '', disabled: true }]
  });

  countries = [
    { code: 'BD', name: 'Bangladesh' },
    { code: 'IN', name: 'India' },
    { code: 'US', name: 'USA' }
  ];

  cities: any[] = [];
  isLoadingCities = false;

  private destroy$ = new Subject<void>();

  constructor(
    private fb: FormBuilder,
    private locationService: LocationService
  ) {}

  ngOnInit(): void {

    // When country changes — load its cities
    this.addressForm.get('country')!.valueChanges
      .pipe(
        takeUntil(this.destroy$),
        tap(() => {
          // Reset city when country changes
          this.cities = [];
          this.addressForm.get('city')!.disable();
          this.addressForm.get('city')!.setValue('');
          this.isLoadingCities = true;
        }),
        switchMap(countryCode => {
          if (!countryCode) return of([]);
          return this.locationService.getCitiesByCountry(countryCode)
            .pipe(catchError(() => of([])));
        })
      )
      .subscribe(cities => {
        this.cities = cities;
        this.isLoadingCities = false;
        if (cities.length > 0) {
          this.addressForm.get('city')!.enable();
        }
      });
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

When the country changes, the city dropdown automatically:

  1. Resets to empty
  2. Shows "Loading cities..."
  3. Disables itself
  4. Loads new cities from API
  5. Re-enables with new options

All powered by valueChanges + switchMap!


💰 Real Example 3 — Live Price Calculator

A checkout form where the price updates as you change quantity and select options:

@Component({
  template: `
    <form [formGroup]="orderForm">
      <label>Quantity</label>
      <input type="number" formControlName="quantity" min="1">

      <label>Size</label>
      <select formControlName="size">
        <option value="small">Small</option>
        <option value="medium">Medium (+ā§ŗ200)</option>
        <option value="large">Large (+ā§ŗ400)</option>
      </select>

      <label>
        <input type="checkbox" formControlName="giftWrap">
        Gift Wrap (+ā§ŗ50)
      </label>

      <div class="price-summary">
        <p>Base price: ā§ŗ{{ basePrice }}</p>
        <p>Total: <strong>ā§ŗ{{ liveTotal$ | async }}</strong></p>
      </div>
    </form>
  `
})
export class OrderFormComponent implements OnInit {

  basePrice = 500;

  orderForm = this.fb.group({
    quantity: [1],
    size: ['small'],
    giftWrap: [false]
  });

  liveTotal$!: Observable<number>;

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    const sizeAddon: Record<string, number> = {
      small: 0,
      medium: 200,
      large: 400
    };

    // Calculate total every time ANY field changes
    this.liveTotal$ = this.orderForm.valueChanges.pipe(
      startWith(this.orderForm.value),
      map(values => {
        const qty = values.quantity || 1;
        const sizeExtra = sizeAddon[values.size] || 0;
        const giftExtra = values.giftWrap ? 50 : 0;
        return (this.basePrice + sizeExtra + giftExtra) * qty;
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The total updates instantly as the user changes any field — no button, no event handlers, just pure reactive magic!


🔧 Using statusChanges for Async Validators

ngOnInit(): void {
  // React to form validity changes
  this.registerForm.statusChanges
    .pipe(
      distinctUntilChanged(),
      takeUntil(this.destroy$)
    )
    .subscribe(status => {
      // 'VALID', 'INVALID', 'PENDING', 'DISABLED'
      if (status === 'VALID') {
        this.canSubmit = true;
        this.showSuccessHint = true;
      } else {
        this.canSubmit = false;
        this.showSuccessHint = false;
      }
    });
}
Enter fullscreen mode Exit fullscreen mode

🧠 Chapter 8 Summary — What You Learned

  • FormControl.valueChanges is an Observable — you can use all RxJS operators on it
  • FormControl.statusChanges tells you when validity changes
  • Use debounceTime + switchMap for async validation (username check, email uniqueness)
  • Use valueChanges + switchMap for dependent dropdowns (country → city)
  • Use valueChanges + map + startWith for live price calculators and previews
  • Always clean up form subscriptions with takeUntil or async pipe

📚 Coming Up in Chapter 9...

Chapter 9 covers debounceTime, throttleTime, distinctUntilChanged — the timing operators.

These are essential for search boxes, scroll events, resize events, and any situation where you need to control HOW OFTEN your code runs.

See you in Chapter 9! 🚀


💌 RxJS Deep Dive Newsletter Series | Chapter 8 of 10
Follow me on : Github Linkedin Threads Youtube Channel

Top comments (0)