Angular's reactive forms and Bootstrap 5's validation styles are a natural fit. Bootstrap provides the CSS (is-valid, is-invalid, valid-feedback, invalid-feedback), Angular provides the logic. Here's how to wire them together properly.

Setup

Make sure Bootstrap is in your angular.json:

"styles": ["node_modules/bootstrap/dist/css/bootstrap.min.css"],
"scripts": ["node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"]

Import ReactiveFormsModule in your component:

import { ReactiveFormsModule } from '@angular/forms'

@Component({
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  // ...
})

Basic Login Form with Validation

// login.component.ts
import { Component } from '@angular/core'
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'
import { CommonModule } from '@angular/common'

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule],
  templateUrl: './login.component.html'
})
export class LoginComponent {
  form: FormGroup

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]]
    })
  }

  // Helper to check if field should show invalid state
  isInvalid(field: string): boolean {
    const control = this.form.get(field)
    return !!(control?.invalid && control?.touched)
  }

  isValid(field: string): boolean {
    const control = this.form.get(field)
    return !!(control?.valid && control?.touched)
  }

  onSubmit() {
    this.form.markAllAsTouched()
    if (this.form.invalid) return
    console.log('Form valid:', this.form.value)
  }
}
<!-- login.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
  <div class="mb-3">
    <label class="form-label">Email Address</label>
    <input
      type="email"
      class="form-control"
      formControlName="email"
      [class.is-invalid]="isInvalid('email')"
      [class.is-valid]="isValid('email')"
      placeholder="name@example.com">

    <div class="invalid-feedback">
      <ng-container *ngIf="form.get('email')?.hasError('required')">
        Email is required.
      </ng-container>
      <ng-container *ngIf="form.get('email')?.hasError('email')">
        Please enter a valid email address.
      </ng-container>
    </div>
    <div class="valid-feedback">Looks good!</div>
  </div>

  <div class="mb-3">
    <label class="form-label">Password</label>
    <input
      type="password"
      class="form-control"
      formControlName="password"
      [class.is-invalid]="isInvalid('password')"
      [class.is-valid]="isValid('password')"
      placeholder="Min 8 characters">

    <div class="invalid-feedback">
      <ng-container *ngIf="form.get('password')?.hasError('required')">
        Password is required.
      </ng-container>
      <ng-container *ngIf="form.get('password')?.hasError('minlength')">
        Password must be at least 8 characters.
      </ng-container>
    </div>
  </div>

  <button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>

Password Confirmation Validator

The cross-field validation pattern — comparing password and confirm password:

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

export const passwordMatchValidator: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const password = group.get('password')?.value
  const confirm = group.get('confirmPassword')?.value
  return password === confirm ? null : { passwordMismatch: true }
}

Apply to the FormGroup:

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

Show the error:

<div class="invalid-feedback d-block"
  *ngIf="form.hasError('passwordMismatch') && form.get('confirmPassword')?.touched">
  Passwords do not match.
</div>

Note: cross-field errors are on the FormGroup, not the individual control, so use form.hasError('passwordMismatch') not form.get('confirmPassword')?.hasError(...).

Custom Validator

Create a validator function for custom rules — username availability, specific format requirements:

// username.validator.ts
import { AbstractControl, ValidationErrors } from '@angular/forms'

export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value && /\s/.test(control.value)) {
    return { noSpaces: true }
  }
  return null
}

export function usernameFormatValidator(control: AbstractControl): ValidationErrors | null {
  const pattern = /^[a-zA-Z0-9_]{3,20}$/
  if (control.value && !pattern.test(control.value)) {
    return { invalidUsername: true }
  }
  return null
}

Use:

username: ['', [Validators.required, usernameFormatValidator]]
<div class="invalid-feedback">
  <ng-container *ngIf="form.get('username')?.hasError('invalidUsername')">
    Username must be 3-20 characters, letters, numbers and underscores only.
  </ng-container>
</div>

Reusable Validation Helper

Typing form.get('field')?.invalid && form.get('field')?.touched everywhere gets old. Create a helper method:

// In your component
get f() {
  return this.form.controls
}

fieldError(field: string): string | null {
  const control = this.f[field]
  if (!control || !control.touched || !control.errors) return null

  const errors = control.errors
  if (errors['required']) return 'This field is required'
  if (errors['email']) return 'Please enter a valid email'
  if (errors['minlength']) return `Minimum ${errors['minlength'].requiredLength} characters`
  if (errors['maxlength']) return `Maximum ${errors['maxlength'].requiredLength} characters`
  if (errors['pattern']) return 'Invalid format'

  return 'Invalid value'
}
<input class="form-control" formControlName="email"
  [class.is-invalid]="f['email'].invalid && f['email'].touched">
<div class="invalid-feedback">{{ fieldError('email') }}</div>

Much cleaner templates.

Real-Time Validation

For real-time feedback as the user types, remove the touched check — but only after the user has started filling in the field:

shouldShowError(field: string): boolean {
  const control = this.form.get(field)
  // Show error if dirty (user typed something) and invalid
  return !!(control?.dirty && control?.invalid)
}

This gives immediate feedback as the user types rather than waiting for them to leave the field.

Frequently Asked Questions

Bind the class conditionally: [class.is-invalid]="form.get('email')?.invalid && form.get('email')?.touched". This adds is-invalid when the field is both invalid and has been touched (user interacted with it). Always check touched to avoid showing errors on untouched fields.
Check specific errors using hasError: *ngIf="form.get('email')?.hasError('required') && form.get('email')?.touched" for required error, and *ngIf="form.get('email')?.hasError('email')" for email format error. Each error has its own div.
Create a custom cross-field validator as a FormGroup validator: the validator receives the group and compares password and confirmPassword controls. Return null if they match, return { passwordMismatch: true } if they don't.
Call form.markAllAsTouched() on submit: onSubmit() { this.form.markAllAsTouched(); if(this.form.invalid) return; // proceed }. This marks all fields as touched which triggers Bootstrap's invalid styling on all invalid fields at once.
touched means the user has focused and left the field. dirty means the value has changed. pristine is the opposite of dirty (value unchanged). For showing validation errors use touched — it shows errors after the user has interacted with a field, not before.

Related

Need a Full Angular + Bootstrap Admin Dashboard?

Marvel Angular Dashboard — Angular 21 + Bootstrap 5 with 50+ components and dark mode.

Browse Templates →

Use code FIRST30 for 30% off.