What Are Reactive Forms?
Reactive forms provide a model-driven approach where the form structure and validation are defined in the component class using TypeScript. This gives you:
- Full control over the form model
- Easy unit testing
- Dynamic form construction
- Complex cross-field validation
- Observable-based value tracking
Setup
Import ReactiveFormsModule:
typescript
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent { }Core Building Blocks
| Class | Purpose |
|---|---|
FormControl | A single input field |
FormGroup | A group of controls |
FormArray | A dynamic array of controls |
FormBuilder | Helper to create forms concisely |
Creating a Reactive Form
With FormGroup and FormControl
typescript
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div>
<label>Email</label>
<input formControlName="email" type="email">
@if (loginForm.get('email')?.invalid && loginForm.get('email')?.touched) {
<span class="error">Valid email is required</span>
}
</div>
<div>
<label>Password</label>
<input formControlName="password" type="password">
@if (loginForm.get('password')?.invalid && loginForm.get('password')?.touched) {
<span class="error">Password must be at least 6 characters</span>
}
</div>
<label>
<input type="checkbox" formControlName="rememberMe">
Remember me
</label>
<button type="submit" [disabled]="loginForm.invalid">Log In</button>
</form>
`,
})
export class LoginComponent {
loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(6)]),
rememberMe: new FormControl(false),
});
onSubmit() {
if (this.loginForm.valid) {
console.log(this.loginForm.value);
// { email: '...', password: '...', rememberMe: true/false }
}
}
}With FormBuilder (Cleaner)
typescript
import { FormBuilder, Validators } from '@angular/forms';
export class LoginComponent {
private fb = inject(FormBuilder);
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
rememberMe: [false],
});
}Accessing Form Values
typescript
// Get entire form value
const formValue = this.loginForm.value;
// { email: 'user@test.com', password: '123456', rememberMe: true }
// Get individual control value
const email = this.loginForm.get('email')?.value;
// Using getRawValue (includes disabled controls)
const rawValue = this.loginForm.getRawValue();Reacting to Value Changes
typescript
ngOnInit() {
// Watch individual control
this.loginForm.get('email')?.valueChanges.subscribe(value => {
console.log('Email changed:', value);
});
// Watch entire form
this.loginForm.valueChanges.subscribe(value => {
console.log('Form changed:', value);
});
// Watch form status
this.loginForm.statusChanges.subscribe(status => {
console.log('Form status:', status); // 'VALID' | 'INVALID' | 'PENDING'
});
}Programmatically Updating Values
typescript
// Set a single control
this.loginForm.get('email')?.setValue('new@email.com');
// Patch partial values (only update specified fields)
this.loginForm.patchValue({
email: 'updated@email.com',
});
// Set entire form value (all fields required)
this.loginForm.setValue({
email: 'full@email.com',
password: 'newpass123',
rememberMe: true,
});
// Reset the form
this.loginForm.reset();
// Reset with specific values
this.loginForm.reset({ email: '', password: '', rememberMe: false });Nested Form Groups
typescript
profileForm = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
address: this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
state: [''],
zip: ['', [Validators.required, Validators.pattern(/^\d{5,6}$/)]],
}),
});html
<form [formGroup]="profileForm" (ngSubmit)="save()">
<input formControlName="name" placeholder="Name">
<input formControlName="email" placeholder="Email">
<div formGroupName="address">
<input formControlName="street" placeholder="Street">
<input formControlName="city" placeholder="City">
<input formControlName="state" placeholder="State">
<input formControlName="zip" placeholder="ZIP Code">
</div>
<button type="submit">Save</button>
</form>FormArray — Dynamic Fields
FormArray lets you add/remove controls dynamically:
typescript
@Component({
selector: 'app-skills-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Your name">
<h3>Skills</h3>
<div formArrayName="skills">
@for (skill of skills.controls; track $index; let i = $index) {
<div>
<input [formControlName]="i" [placeholder]="'Skill ' + (i + 1)">
<button type="button" (click)="removeSkill(i)">✕</button>
</div>
}
</div>
<button type="button" (click)="addSkill()">+ Add Skill</button>
<button type="submit">Save</button>
</form>
`,
})
export class SkillsFormComponent {
private fb = inject(FormBuilder);
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);
}
onSubmit() {
console.log(this.form.value);
// { name: 'John', skills: ['TypeScript', 'Angular', 'RxJS'] }
}
}Typed Forms (Angular 14+)
Angular's reactive forms are strictly typed by default:
typescript
// TypeScript knows the exact shape
const form = this.fb.group({
name: [''],
age: [0],
active: [true],
});
const name: string | null = form.value.name; // Typed!
const age: number | null = form.value.age; // Typed!
const active: boolean | null = form.value.active; // Typed!Non-Nullable Forms
typescript
const form = this.fb.nonNullable.group({
name: [''],
age: [0],
});
// Values are never null after reset
form.reset();
console.log(form.value.name); // '' (not null)Enabling/Disabling Controls
typescript
// Disable a control
this.form.get('email')?.disable();
// Enable a control
this.form.get('email')?.enable();
// Disable on condition
if (this.isReadOnly) {
this.form.disable();
}Note: Disabled controls are excluded from form.value. Use form.getRawValue() to include them.
Complete Example: Order Form
typescript
@Component({
selector: 'app-order-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="orderForm" (ngSubmit)="placeOrder()">
<h2>Place Order</h2>
<div formGroupName="customer">
<input formControlName="name" placeholder="Full Name">
<input formControlName="email" placeholder="Email">
<input formControlName="phone" placeholder="Phone">
</div>
<h3>Items</h3>
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i" class="item-row">
<input formControlName="product" placeholder="Product">
<input formControlName="quantity" type="number" min="1">
<input formControlName="price" type="number" step="0.01">
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">+ Add Item</button>
<p><strong>Total: {{ calculateTotal() | currency }}</strong></p>
<button type="submit" [disabled]="orderForm.invalid">
Place Order
</button>
</form>
`,
})
export class OrderFormComponent {
private fb = inject(FormBuilder);
orderForm = this.fb.group({
customer: this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
phone: [''],
}),
items: this.fb.array([this.createItem()]),
});
get items() {
return this.orderForm.get('items') as FormArray;
}
createItem() {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
price: [0, [Validators.required, Validators.min(0.01)]],
});
}
addItem() { this.items.push(this.createItem()); }
removeItem(i: number) { this.items.removeAt(i); }
calculateTotal(): number {
return this.items.controls.reduce((sum, item) => {
const qty = item.get('quantity')?.value || 0;
const price = item.get('price')?.value || 0;
return sum + qty * price;
}, 0);
}
placeOrder() {
if (this.orderForm.valid) {
console.log('Order:', this.orderForm.value);
}
}
}Next Steps
Forms need validation. Next, we'll explore form validation in depth — custom validators, async validators, and cross-field validation strategies.