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
| Validator | Usage |
|---|---|
Validators.required | Field must not be empty |
Validators.email | Must 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.nullValidator | Always 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
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.