DEV Community

Cover image for Angular Reactive Forms Complete Guide: FormBuilder, Validators & Custom Validation
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

Angular Reactive Forms Complete Guide: FormBuilder, Validators & Custom Validation

Building forms in Angular used to be one of my least favorite tasks. Between managing form state, handling validation, and dealing with dynamic fields, it felt like I was writing more boilerplate than actual logic. Then I discovered Angular Reactive Forms, and everything changed. Instead of fighting with template-driven forms, I could build complex, validated forms with clean, testable code.

Angular Reactive Forms use a model-driven approach, which means you define your form structure in TypeScript rather than in the template. This gives you programmatic control over form state, validation, and dynamic behavior. FormBuilder makes it easy to create form groups, FormControl handles individual fields, and Validators provide built-in and custom validation rules.

πŸ“– Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.

What are Angular Reactive Forms?

Angular Reactive Forms provide:

  • Model-driven approach - Define forms in TypeScript
  • Programmatic control - Full control over form state and validation
  • Type safety - TypeScript support for form structures
  • Testability - Easy to unit test form logic
  • Dynamic forms - Add/remove form controls dynamically
  • Complex validation - Built-in and custom validators
  • Better performance - More efficient than template-driven forms

Setting Up Reactive Forms

First, import ReactiveFormsModule in your Angular module:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    BrowserModule,
    ReactiveFormsModule
  ],
  declarations: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

ReactiveFormsModule provides the directives and classes needed for Reactive Forms, including FormGroup, FormControl, FormBuilder, and form validation directives.

Basic Form with FormBuilder

Create a form using FormBuilder for cleaner syntax:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-business-settings',
  templateUrl: './business-settings.component.html',
  styleUrls: ['./business-settings.component.scss']
})
export class BusinessSettingsComponent implements OnInit {
  public form: FormGroup;
  public save_loading: boolean = false;

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.form = this.fb.group({
      exitReport: ['', [Validators.required]],
      notificationType: ['', [Validators.required]],
      retainPeriod: ['', [Validators.required]],
      checkInRadius: ['', [Validators.required]],
      checkOutRadius: ['', [Validators.required]],
      vendorAdminSeats: [3],
      systemCheckoutId: [''],
      businessSectorId: [''],
      approved: [false],
      openAssociation: [false],
      editWorkflow: [false]
    });
  }

  public save(): void {
    this.form.markAllAsTouched();
    if (this.form.valid) {
      this.save_loading = true;
      const formData = this.form.getRawValue();
      // Handle form submission
      console.log('Form Data:', formData);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

FormBuilder.group() creates a FormGroup with multiple FormControls. Each control can have an initial value and an array of validators. The form.getRawValue() method retrieves all form values, including disabled controls.

Template Binding

Bind the form to your template using [formGroup] and formControlName:

<form [formGroup]="form" (ngSubmit)="save()">
  <div class="form-group">
    <label>Exit Report</label>
    <select formControlName="exitReport" class="form-control">
      <option value="">Select Exit Report Type</option>
      <option value="not-issued">Not Issued</option>
      <option value="issued">Issued</option>
    </select>
    <div *ngIf="form.get('exitReport')?.invalid && form.get('exitReport')?.touched" 
         class="error-message">
      Exit Report is required
    </div>
  </div>

  <div class="form-group">
    <label>Notification Type</label>
    <select formControlName="notificationType" class="form-control">
      <option value="">Select Notification Type</option>
      <option [value]="1">Email</option>
      <option [value]="2">SMS</option>
    </select>
    <div *ngIf="form.get('notificationType')?.invalid && form.get('notificationType')?.touched" 
         class="error-message">
      Notification Type is required
    </div>
  </div>

  <div class="form-group">
    <label>Check In Radius (meters)</label>
    <input type="number" formControlName="checkInRadius" class="form-control" />
    <div *ngIf="form.get('checkInRadius')?.invalid && form.get('checkInRadius')?.touched" 
         class="error-message">
      Check In Radius is required
    </div>
  </div>

  <div class="form-group">
    <label>
      <input type="checkbox" formControlName="approved" />
      Approval Required
    </label>
  </div>

  <button type="submit" [disabled]="save_loading || form.invalid" class="btn btn-primary">
    <span *ngIf="save_loading">Saving...</span>
    <span *ngIf="!save_loading">Save Settings</span>
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

The [formGroup] directive binds the FormGroup to the form element. formControlName binds individual FormControls to input elements. Check form validity and touched state to display validation errors appropriately.

Built-in Validators

Angular provides several built-in validators:

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

this.form = this.fb.group({
  // Required validator
  name: ['', [Validators.required]],

  // Email validator
  email: ['', [Validators.required, Validators.email]],

  // Min/Max length validators
  password: ['', [
    Validators.required,
    Validators.minLength(8),
    Validators.maxLength(20)
  ]],

  // Pattern validator (regex)
  phoneNumber: ['', [
    Validators.required,
    Validators.pattern(/^[0-9]{10}$/)
  ]],

  // Min/Max value validators (for numbers)
  age: ['', [
    Validators.required,
    Validators.min(18),
    Validators.max(100)
  ]],

  // Multiple validators
  username: ['', [
    Validators.required,
    Validators.minLength(3),
    Validators.pattern(/^[a-zA-Z0-9_]+$/)
  ]]
});
Enter fullscreen mode Exit fullscreen mode

Validators can be combined in an array. All validators must pass for the control to be valid. Use form.get('controlName')?.hasError('errorKey') to check specific validation errors.

Common Built-in Validators

  • Validators.required - Field is required
  • Validators.email - Valid email format
  • Validators.minLength(n) - Minimum string length
  • Validators.maxLength(n) - Maximum string length
  • Validators.min(n) - Minimum numeric value
  • Validators.max(n) - Maximum numeric value
  • Validators.pattern(regex) - Pattern matching

Custom Validators

Create custom validators for complex validation logic:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Custom validator function
export function customEmailValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) {
      return null; // Don't validate empty values (use required for that)
    }

    const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
    const isValid = emailPattern.test(control.value);

    return isValid ? null : { invalidEmail: { value: control.value } };
  };
}

// Password strength validator
export function passwordStrengthValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) {
      return null;
    }

    const value = control.value;
    const hasUpperCase = /[A-Z]/.test(value);
    const hasLowerCase = /[a-z]/.test(value);
    const hasNumeric = /[0-9]/.test(value);
    const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);

    const passwordValid = hasUpperCase && hasLowerCase && hasNumeric && hasSpecialChar;

    return passwordValid ? null : { 
      weakPassword: { 
        hasUpperCase,
        hasLowerCase,
        hasNumeric,
        hasSpecialChar
      } 
    };
  };
}

// Usage in component
import { customEmailValidator, passwordStrengthValidator } from './validators';

this.form = this.fb.group({
  email: ['', [Validators.required, customEmailValidator()]],
  password: ['', [Validators.required, passwordStrengthValidator()]]
});
Enter fullscreen mode Exit fullscreen mode

Custom validators are functions that return ValidatorFn. They receive an AbstractControl and return ValidationErrors | null. Return null for valid values, or an object with error keys for invalid values.

Cross-Field Validation

Validate multiple fields together, like password confirmation:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function passwordMatchValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const password = control.get('password');
    const confirmPassword = control.get('confirmPassword');

    if (!password || !confirmPassword) {
      return null;
    }

    const passwordMatch = password.value === confirmPassword.value;

    if (!passwordMatch) {
      confirmPassword.setErrors({ passwordMismatch: true });
      return { passwordMismatch: true };
    } else {
      // Clear the error if passwords match
      if (confirmPassword.hasError('passwordMismatch')) {
        confirmPassword.setErrors(null);
      }
      return null;
    }
  };
}

// Apply validator to FormGroup
this.form = this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', [Validators.required]]
}, { validators: passwordMatchValidator() });

// In template
<div *ngIf="form.hasError('passwordMismatch')" class="error-message">
  Passwords do not match
</div>
Enter fullscreen mode Exit fullscreen mode

Cross-field validators are applied to the FormGroup, not individual controls. They can access multiple controls and set errors on them as needed.

FormArray for Dynamic Forms

Use FormArray to manage dynamic form controls:

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

export class ProductFormComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      productName: ['', Validators.required],
      description: [''],
      categories: this.fb.array([]) // FormArray for dynamic categories
    });
  }

  // Getter for categories FormArray
  get categories(): FormArray {
    return this.form.get('categories') as FormArray;
  }

  // Add a new category
  addCategory(): void {
    const categoryGroup = this.fb.group({
      name: ['', Validators.required],
      description: ['']
    });
    this.categories.push(categoryGroup);
  }

  // Remove a category
  removeCategory(index: number): void {
    this.categories.removeAt(index);
  }

  // Get category at index
  getCategoryAt(index: number): FormGroup {
    return this.categories.at(index) as FormGroup;
  }

  onSubmit(): void {
    if (this.form.valid) {
      const formData = this.form.getRawValue();
      console.log('Form Data:', formData);
      // Handle submission
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Template for FormArray:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div formArrayName="categories">
    <div *ngFor="let category of categories.controls; let i = index" 
         [formGroupName]="i" 
         class="category-group">
      <input formControlName="name" placeholder="Category Name" />
      <input formControlName="description" placeholder="Description" />
      <button type="button" (click)="removeCategory(i)">Remove</button>
    </div>
  </div>

  <button type="button" (click)="addCategory()">Add Category</button>
  <button type="submit">Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Form State Management

Access and manage form state:

// Form state properties
this.form.valid        // true if all controls are valid
this.form.invalid      // true if any control is invalid
this.form.pristine     // true if no controls have been touched
this.form.dirty        // true if any control has been modified
this.form.touched      // true if any control has been touched
this.form.untouched    // true if no controls have been touched
this.form.pending      // true if any async validators are running

// Control state
const control = this.form.get('email');
control?.valid
control?.invalid
control?.pristine
control?.dirty
control?.touched
control?.errors        // Object with validation errors
control?.hasError('required')  // Check specific error

// Form values
this.form.value              // Get all values (excludes disabled)
this.form.getRawValue()      // Get all values (includes disabled)
this.form.valueChanges       // Observable of value changes
this.form.statusChanges      // Observable of status changes

// Setting values
this.form.patchValue({      // Partial update (doesn't require all fields)
  email: 'new@email.com',
  name: 'New Name'
});

this.form.setValue({         // Full update (requires all fields)
  email: 'new@email.com',
  name: 'New Name',
  age: 25
});

// Resetting form
this.form.reset();           // Reset to initial state
this.form.reset({            // Reset with new values
  email: '',
  name: ''
});

// Enabling/Disabling
this.form.disable();         // Disable entire form
this.form.enable();          // Enable entire form
this.form.get('email')?.disable();  // Disable specific control
this.form.get('email')?.enable();   // Enable specific control
Enter fullscreen mode Exit fullscreen mode

Async Validators

Validate against server-side data:

import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { UserService } from './user.service';

export function emailExistsValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) {
      return of(null);
    }

    return control.valueChanges.pipe(
      debounceTime(500),
      distinctUntilChanged(),
      switchMap(email => userService.checkEmailExists(email)),
      map(exists => exists ? { emailExists: true } : null),
      catchError(() => of(null))
    );
  };
}

// Usage
constructor(
  private fb: FormBuilder,
  private userService: UserService
) {
  this.form = this.fb.group({
    email: ['', 
      [Validators.required, Validators.email],
      [emailExistsValidator(this.userService)]
    ]
  });
}
Enter fullscreen mode Exit fullscreen mode

Async validators return Observable<ValidationErrors | null>. They're useful for server-side validation like checking if an email already exists.

Best Practices

  1. Always use FormBuilder - Cleaner syntax and better maintainability
  2. Use markAllAsTouched() - Before validation checks to show all errors
  3. Check form.valid - Before submission to prevent invalid data
  4. Use getRawValue() - When you need disabled control values
  5. Implement proper error handling - User feedback and error messages
  6. Use FormArray - For dynamic form controls that can be added/removed
  7. Create reusable custom validators - For common validation patterns
  8. Use async validators - For server-side validation with debouncing
  9. Disable form controls appropriately - Based on business logic
  10. Reset forms after successful submission - Clean state management
  11. Use patchValue() for partial updates - setValue() for complete updates
  12. Subscribe to valueChanges and statusChanges - For reactive updates

Common Patterns

Conditional Validation

this.form = this.fb.group({
  email: ['', Validators.required],
  phone: ['']
});

// Add conditional validation
this.form.get('email')?.valueChanges.subscribe(email => {
  const phoneControl = this.form.get('phone');
  if (!email) {
    phoneControl?.setValidators([Validators.required]);
  } else {
    phoneControl?.clearValidators();
  }
  phoneControl?.updateValueAndValidity();
});
Enter fullscreen mode Exit fullscreen mode

Nested FormGroups

this.form = this.fb.group({
  personalInfo: this.fb.group({
    firstName: ['', Validators.required],
    lastName: ['', Validators.required]
  }),
  address: this.fb.group({
    street: [''],
    city: ['', Validators.required],
    zipCode: ['']
  })
});
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

Conclusion

Angular Reactive Forms provide a powerful, type-safe way to build complex forms in enterprise applications. With FormBuilder, validators, custom validation, and FormArray, you can create dynamic, validated forms that handle complex business requirements. The patterns shown here are used in production Angular applications for building robust form management systems.

Key Takeaways:

  • Angular Reactive Forms use a model-driven approach for form management
  • FormBuilder simplifies form creation with cleaner syntax
  • Built-in validators cover common validation needs
  • Custom validators handle complex business logic
  • FormArray enables dynamic form controls
  • Cross-field validation validates multiple fields together
  • Async validators handle server-side validation
  • Form state management provides full control over form behavior

Whether you're building a simple contact form or a complex multi-step wizard, Angular Reactive Forms provide the foundation you need. They handle all the form logic while giving you complete control over validation and state management.


What's your experience with Angular Reactive Forms? Share your tips and tricks in the comments below! πŸš€


πŸ’‘ Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.

If you found this guide helpful, consider checking out my other articles on Angular development and frontend development best practices.

Top comments (0)