Create a Basic Reactive Form
Start by importing ReactiveFormsModule in your Angular module:

// app.module.ts
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
  imports: [ReactiveFormsModule, /* other imports */],
})
export class AppModule {}


Then, build a form in your component using FormBuilder:
// user-form.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({ selector: 'app-user-form', templateUrl: './user-form.component.html' })
export class UserFormComponent {
  userForm: FormGroup;

  constructor(private fb: FormBuilder) {
    this.userForm = this.fb.group({
      name: ['', [Validators.required, Validators.minLength(2)]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(6)]],
    });
  }
}


In the template, bind the form and controls:
<!-- user-form.component.html -->
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
  <label>
    Name
    <input formControlName="name" />
  </label>
  <div *ngIf="userForm.get('name')?.touched && userForm.get('name')?.invalid">
    <small *ngIf="userForm.get('name')?.errors?.required">Name is required.</small>
    <small *ngIf="userForm.get('name')?.errors?.minlength">Name must be at least 2 characters.</small>
  </div>

  <label>
    Email
    <input formControlName="email" />
  </label>
  <div *ngIf="userForm.get('email')?.touched && userForm.get('email')?.invalid">
    <small *ngIf="userForm.get('email')?.errors?.required">Email is required.</small>
    <small *ngIf="userForm.get('email')?.errors?.email">Enter a valid email.</small>
  </div>

  <button type="submit" [disabled]="userForm.invalid">Submit</button>
</form>


Built-in Validators
Angular provides several built-in validators:

  • Validators.required — field must have a value.
  • Validators.email — value must be a valid email.
  • Validators.min / Validators.max — numeric limits.
  • Validators.minLength / Validators.maxLength — string length limits.
  • Validators.pattern — regex-based validation.

You can combine validators in an array for a control, as shown in the example above.

Custom Synchronous Validators

For rules that don’t exist out of the box (e.g., username format), write a custom validator function that returns either null (valid) or an error object:
import { AbstractControl, ValidationErrors } from '@angular/forms';

export function usernameValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value as string;
  if (!value) return null;
  const valid = /^[a-z0-9_]+$/.test(value);
  return valid ? null : { invalidUsername: true };
}

// usage in form builder
this.userForm = this.fb.group({
  username: ['', [Validators.required, usernameValidator]],
});

Show helpful messages in the template when invalidUsername exists.

Cross-Field Validation (Password Match)

Some validations depend on multiple controls. Use a validator on the FormGroup:
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return password === confirm ? null : { passwordsMismatch: true };
}

this.userForm = this.fb.group({
  password: ['', Validators.required],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });

In the template, show the group-level error:
<div *ngIf="userForm.errors?.passwordsMismatch && userForm.touched">
  <small>Passwords do not match.</small>
</div>


Async Validators (e.g., Check Email Uniqueness)

Async validators are useful for server checks like "is this email taken?". They return an Observable or Promise.
import { AbstractControl } from '@angular/forms';
import { map } from 'rxjs/operators';
import { of } from 'rxjs';

function uniqueEmailValidator(apiService: ApiService) {
  return (control: AbstractControl) => {
    if (!control.value) return of(null);
    return apiService.checkEmail(control.value).pipe(
      map(isTaken => (isTaken ? { emailTaken: true } : null))
    );
  };
}

// in component
this.userForm = this.fb.group({
  email: ['', {
    validators: [Validators.required, Validators.email],
    asyncValidators: [uniqueEmailValidator(this.apiService)],
    updateOn: 'blur' // run async validator on blur to reduce calls
  }]
});

Use updateOn: 'blur' to prevent calling the server on every keystroke.

Displaying Validation State and UX Tips

  • Show errors only after user interaction — use touched or dirty to avoid overwhelming users with errors on load.
  • Disable submit while invalid — [disabled]="userForm.invalid" prevents sending bad data.
  • Focus the first invalid control — on submit, set focus to the first invalid field for better UX.
  • Use updateOn: 'blur' or debounce — reduces validation frequency and server calls.

Example to focus first invalid:
onSubmit() {
  if (this.userForm.invalid) {
    const invalidControl = this.el.nativeElement.querySelector('.ng-invalid');
    invalidControl?.focus();
    return;
  }
  // process valid form
}

Reacting to Value Changes and Live Validation
You can subscribe to valueChanges for any control or the whole form to implement live validation messages, dynamic rules, or enable/disable fields.
this.userForm.get('country')?.valueChanges.subscribe(country => {
  if (country === 'US') {
    this.userForm.get('state')?.setValidators([Validators.required]);
  } else {
    this.userForm.get('state')?.clearValidators();
  }
  this.userForm.get('state')?.updateValueAndValidity();
});

Remember to unsubscribe in ngOnDestroy or use the takeUntil pattern.

Integrating with Backend Validation
Server-side validation is the final source of truth. When the backend returns validation errors, map them to form controls so users can correct them:
// after API error response
handleServerErrors(errors: Record<string, string[]>) {
  Object.keys(errors).forEach(field => {
    const control = this.userForm.get(field);
    if (control) {
      control.setErrors({ server: errors[field][0] });
    }
  });
}


Show control.errors.server messages in the template.

Testing Form Validation
Unit test reactive forms by creating the component, setting values, and asserting validity:
it('should invalidate empty email', () => {
  component.userForm.get('email')?.setValue('');
  expect(component.userForm.get('email')?.valid).toBeFalse();
});


For async validators, use fakeAsync and tick() to simulate time.

  • Accessibility (A11y) Considerations
  • Always link error messages to inputs with aria-describedby.
  • Use clear error language and avoid technical terms.
  • Ensure focus management sends keyboard users to errors on submit.

Example
<input id="email" formControlName="email" aria-describedby="emailError" />
<div id="emailError" *ngIf="userForm.get('email')?.invalid">
  <small>Enter a valid email address.</small>
</div>

Performance Tips and Best Practices

  • Use OnPush change detection where appropriate to reduce re-renders.
  • Avoid heavy computation inside valueChanges subscribers.
  • Use debounceTime for expensive validations or server calls:

this.userForm.get('search')?.valueChanges.pipe(debounceTime(300)).subscribe(...);

Clean up subscriptions with takeUntil or async pipe.

Summary
An effective, testable method for managing form validation is provided by Angular's Reactive Forms. For common rules, use the built-in validators; for special cases, create your own sync and async validators; and for cross-field checks, such as password confirmation, use group validators. Enhance the user experience by integrating server-side errors using setErrors, emphasizing the initial incorrect control, and displaying errors upon interaction. Use performance techniques like debouncing and OnPush change detection, test your validations, and consider accessibility.