Reactive forms give you full programmatic control over your form state — structure defined in TypeScript, not in the template. Here's everything you need to build production forms in Angular 21 with Bootstrap 5 styling.

Setup

Import ReactiveFormsModule in your standalone component:

import { Component } from '@angular/core'
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgClass } from '@angular/common'

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule, NgIf, NgClass],
  templateUrl: './login.component.html'
})
export class LoginComponent {
  constructor(private fb: FormBuilder) {}
}

Basic FormGroup with Validation

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)]],
      remember: [false]
    })
  }

  // Shorthand helpers for template
  get email()    { return this.form.get('email') }
  get password() { return this.form.get('password') }

  onSubmit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched()
      return
    }
    console.log(this.form.value)
    // { email: '...', password: '...', remember: false }
  }
}
<!-- login.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
  <div class="mb-3">
    <label class="form-label fw-semibold">Email</label>
    <input type="email" class="form-control"
      formControlName="email"
      [ngClass]="{ 'is-invalid': email?.invalid && email?.touched }">
    <div class="invalid-feedback">
      <span *ngIf="email?.errors?.['required']">Email is required.</span>
      <span *ngIf="email?.errors?.['email']">Enter a valid email address.</span>
    </div>
  </div>

  <div class="mb-3">
    <label class="form-label fw-semibold">Password</label>
    <input type="password" class="form-control"
      formControlName="password"
      [ngClass]="{ 'is-invalid': password?.invalid && password?.touched }">
    <div class="invalid-feedback">
      <span *ngIf="password?.errors?.['required']">Password is required.</span>
      <span *ngIf="password?.errors?.['minlength']">Minimum 8 characters.</span>
    </div>
  </div>

  <div class="form-check mb-4">
    <input class="form-check-input" type="checkbox" formControlName="remember" id="remember">
    <label class="form-check-label" for="remember">Remember me</label>
  </div>

  <button type="submit" class="btn w-100 text-white fw-semibold"
    style="background:#fd4766;" [disabled]="form.invalid">
    Sign In
  </button>
</form>

Nested FormGroups

Use nested groups to organize related fields:

this.form = this.fb.group({
  personal: this.fb.group({
    firstName: ['', Validators.required],
    lastName:  ['', Validators.required],
  }),
  contact: this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    phone: ['', Validators.pattern(/^[0-9]{10}$/)],
  }),
  address: this.fb.group({
    street: ['', Validators.required],
    city:   ['', Validators.required],
    pin:    ['', [Validators.required, Validators.minLength(6)]],
  })
})
<div formGroupName="personal">
  <input class="form-control" formControlName="firstName" placeholder="First name">
</div>

<div formGroupName="contact">
  <input class="form-control" formControlName="email" type="email">
</div>

Access values: this.form.get('personal.firstName')?.value or this.form.value.personal.firstName.

FormArray — Dynamic Fields

FormArray handles lists of controls — skills, phone numbers, experience entries:

import { FormArray } from '@angular/forms'

export class ProfileComponent {
  form = this.fb.group({
    name:   ['', Validators.required],
    skills: this.fb.array([
      this.fb.control('', Validators.required)
    ])
  })

  get skills() {
    return this.form.get('skills') as FormArray
  }

  addSkill() {
    this.skills.push(this.fb.control('', Validators.required))
  }

  removeSkill(index: number) {
    this.skills.removeAt(index)
  }

  constructor(private fb: FormBuilder) {}
}
<div formArrayName="skills">
  <div *ngFor="let skill of skills.controls; let i = index"
    class="d-flex gap-2 mb-2">
    <input class="form-control" [formControlName]="i" placeholder="e.g. Angular">
    <button type="button" class="btn btn-outline-danger btn-sm" (click)="removeSkill(i)">
      ✕
    </button>
  </div>
</div>

<button type="button" class="btn btn-outline-secondary btn-sm mt-1" (click)="addSkill()">
  + Add Skill
</button>

Built-in Validators Reference

ValidatorUsage
Validators.requiredField must not be empty
Validators.emailMust be a valid email format
Validators.minLength(n)String must be at least n characters
Validators.maxLength(n)String must be at most n characters
Validators.min(n)Number must be ≥ n
Validators.max(n)Number must be ≤ n
Validators.pattern(regex)Must match regular expression
Validators.nullValidatorAlways valid (no-op placeholder)

Custom Validators

A validator is a function that returns null (valid) or an error object (invalid):

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

// Simple custom validator
export function noSpaces(control: AbstractControl): ValidationErrors | null {
  if (control.value && control.value.includes(' ')) {
    return { noSpaces: 'Username cannot contain spaces' }
  }
  return null
}

// Validator factory — returns a validator with config
export function minAge(min: number) {
  return (control: AbstractControl): ValidationErrors | null => {
    const age = parseInt(control.value)
    if (isNaN(age) || age < min) {
      return { minAge: `Must be at least ${min} years old` }
    }
    return null
  }
}

// Cross-field validator (on FormGroup, not individual control)
export function passwordMatch(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value
  const confirm  = group.get('confirmPassword')?.value
  return password === confirm ? null : { passwordMismatch: true }
}
// Usage
this.form = this.fb.group({
  username:        ['', [Validators.required, noSpaces]],
  age:             ['', [Validators.required, minAge(18)]],
  password:        ['', Validators.required],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatch })
<!-- Cross-field error (on the form, not a field) -->
<div *ngIf="form.errors?.['passwordMismatch'] && form.touched"
  class="alert alert-danger py-2 small">
  Passwords do not match.
</div>

Watching Form Value Changes

ngOnInit() {
  // Watch a single field
  this.form.get('email')?.valueChanges.subscribe(value => {
    console.log('Email changed:', value)
  })

  // Watch the whole form
  this.form.valueChanges.subscribe(values => {
    console.log('Form values:', values)
  })

  // Conditionally show/hide a field based on another
  this.form.get('accountType')?.valueChanges.subscribe(type => {
    if (type === 'business') {
      this.form.get('companyName')?.setValidators([Validators.required])
    } else {
      this.form.get('companyName')?.clearValidators()
    }
    this.form.get('companyName')?.updateValueAndValidity()
  })
}

Resetting and Patching

// Reset to empty/default values
this.form.reset()

// Reset to specific values
this.form.reset({ email: '', password: '', remember: false })

// Patch — update only specified fields, leave others untouched
this.form.patchValue({ email: 'gagan@lettstartdesign.com' })

// setValue — must provide ALL fields or it throws
this.form.setValue({ email: '...', password: '...', remember: false })

patchValue is the one you'll use most when loading API data into a form — it doesn't care if the API response has extra or missing keys.

Frequently Asked Questions

Reactive forms define the form structure in the component class using FormGroup/FormControl — giving you full programmatic control, easier testing, and better handling of dynamic fields. Template-driven forms use ngModel directives in the template — simpler for basic forms but harder to test and scale.
Use form.value to get all values as an object, or form.get('fieldName')?.value for a specific field. form.getRawValue() includes disabled fields that form.value excludes.
Use FormArray. Get the array with form.get('items') as FormArray, then call .push(this.fb.group({...})) to add a new row and .removeAt(index) to delete one.
A custom validator is a function that takes an AbstractControl and returns null (valid) or an object like { myError: true } (invalid). Pass it in the validators array: fb.control('', [Validators.required, myCustomValidator]).

Need a Full Bootstrap 5 Admin Dashboard?

Get a complete Angular 21 + Bootstrap 5 dashboard with 50+ components — built by the same team behind BootstrapPlanet.

Browse Templates →

Use code FIRST30 for 30% off your first purchase.

Related Components