Angular has two form approaches: template-driven forms (using ngModel) and reactive forms (using FormControl, FormGroup, and FormArray). Reactive forms are the right choice for any form that needs validation logic, dynamic fields, programmatic control, unit testability, or complex multi-step workflows. The form model lives entirely in the TypeScript class — the template just binds to it. This separation makes reactive forms powerful, predictable, and independent of the DOM. This lesson builds a complete reactive form system for the task manager application.
Reactive Forms Classes
| Class | Purpose | Tracks |
|---|---|---|
FormControl<T> |
Single input — one field | Value, validity, touched, dirty state |
FormGroup |
Named group of controls — one form or section | Aggregated validity, value as object |
FormArray |
Ordered array of controls — dynamic fields | Array of controls, indexed access |
FormBuilder |
Shorthand factory for creating controls | N/A — just a factory |
FormRecord |
Map of controls with dynamic keys | Like FormGroup but with dynamic key types |
Control State Properties
| Property | Meaning |
|---|---|
valid / invalid |
All validators pass / any validator fails |
touched / untouched |
User has focused and blurred / never focused |
dirty / pristine |
Value changed since last reset / never changed |
pending |
Async validator is running |
disabled / enabled |
Control is disabled / active |
value |
Current typed value (includes disabled controls) |
getRawValue() |
Value including disabled controls |
errors |
Object with error keys ({ required: true, minlength: { ... } }) or null |
valueChanges |
Observable that emits on every value change |
statusChanges |
Observable that emits on validity status change |
ReactiveFormsModule, not FormsModule. In standalone components, import ReactiveFormsModule (for the full module) or the individual directives: ReactiveFormsDirective, FormControlDirective, FormGroupDirective, FormArrayName. The most common approach is importing ReactiveFormsModule in the component’s imports array. Do not mix FormsModule (template-driven) and ReactiveFormsModule directives in the same form.FormControl<T>, FormGroup, and FormBuilder.group(). Declaring title = new FormControl('', { nonNullable: true }) means titleControl.value is typed as string rather than string | null. The nonNullable: true option means calling reset() restores the initial value rather than setting to null. This eliminates many null checks and type assertions in form submission handlers.form.markAllAsTouched() before checking validity on form submit. Validation errors are only displayed when a control is “touched” — the user has focused and blurred the input. If the user clicks submit without touching any field, the errors are computed but not displayed because all controls are untouched. markAllAsTouched() marks every control as touched, triggering error display for all invalid fields simultaneously.Complete Reactive Form Implementation
// features/tasks/task-form/task-form.component.ts
import {
Component, OnInit, Input, signal, computed, inject,
} from '@angular/core';
import {
FormBuilder, FormGroup, FormArray, FormControl,
Validators, AbstractControl, AsyncValidatorFn,
ReactiveFormsModule, NonNullableFormBuilder,
} from '@angular/forms';
import { CommonModule, DatePipe } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { debounceTime, distinctUntilChanged, switchMap, map, first } from 'rxjs/operators';
import { of } from 'rxjs';
import { TaskService } from '../../../core/services/task.service';
import { TaskStore } from '../../../core/stores/task.store';
import { Task } from '../../../shared/models/task.model';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
// Typed form value interface
interface TaskFormValue {
title: string;
description: string;
priority: 'low' | 'medium' | 'high';
dueDate: string;
tags: string[];
assignee: {
userId: string;
name: string;
};
}
@Component({
selector: 'app-task-form',
standalone: true,
imports: [ReactiveFormsModule, CommonModule, DatePipe],
templateUrl:'./task-form.component.html',
})
export class TaskFormComponent implements OnInit {
@Input() existingTask?: Task; // set when editing
private fb = inject(NonNullableFormBuilder); // .group() returns non-null types
private taskStore = inject(TaskStore);
private router = inject(Router);
private route = inject(ActivatedRoute);
submitting = signal(false);
submitError= signal<string | null>(null);
// ── Form definition ───────────────────────────────────────────────────
form = this.fb.group({
title: ['', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(200),
]],
description: ['', [
Validators.maxLength(2000),
]],
priority: ['medium' as 'low' | 'medium' | 'high', [
Validators.required,
]],
dueDate: ['', [
this.futureDateValidator(),
]],
tags: this.fb.array<FormControl<string>>([]),
// Nested group
assignee: this.fb.group({
userId: [''],
name: [''],
}),
});
// ── Computed from form state ──────────────────────────────────────────
isEditing = computed(() => !!this.existingTask);
get titleCtrl() { return this.form.controls.title; }
get descCtrl() { return this.form.controls.description; }
get priorityCtrl() { return this.form.controls.priority; }
get dueDateCtrl() { return this.form.controls.dueDate; }
get tagsArray() { return this.form.controls.tags; }
get assigneeGroup() { return this.form.controls.assignee; }
hasUnsavedChanges(): boolean { return this.form.dirty; }
ngOnInit(): void {
// Patch form with existing task data when editing
if (this.existingTask) {
this.form.patchValue({
title: this.existingTask.title,
description: this.existingTask.description ?? '',
priority: this.existingTask.priority,
dueDate: this.existingTask.dueDate
? new Date(this.existingTask.dueDate).toISOString().split('T')[0]
: '',
});
// Populate tags FormArray
this.existingTask.tags?.forEach(tag => this.addTag(tag));
}
// Dynamic validation — dueDate required for high priority
this.priorityCtrl.valueChanges.pipe(
takeUntilDestroyed(),
).subscribe(priority => {
if (priority === 'high') {
this.dueDateCtrl.addValidators(Validators.required);
} else {
this.dueDateCtrl.removeValidators(Validators.required);
}
this.dueDateCtrl.updateValueAndValidity();
});
}
// ── FormArray operations ──────────────────────────────────────────────
addTag(value = ''): void {
if (this.tagsArray.length >= 10) return;
this.tagsArray.push(this.fb.control(value, [
Validators.required,
Validators.minLength(1),
Validators.maxLength(50),
]));
}
removeTag(index: number): void {
this.tagsArray.removeAt(index);
}
moveTag(from: number, to: number): void {
const ctrl = this.tagsArray.at(from);
this.tagsArray.removeAt(from);
this.tagsArray.insert(to, ctrl);
}
// ── Custom validator ──────────────────────────────────────────────────
private futureDateValidator() {
return (control: AbstractControl): { [key: string]: any } | null => {
if (!control.value) return null; // empty is ok (unless required)
const selected = new Date(control.value);
const today = new Date();
today.setHours(0, 0, 0, 0);
return selected < today ? { pastDate: true } : null;
};
}
// ── Async validator — check duplicate title ───────────────────────────
private uniqueTitleValidator(): AsyncValidatorFn {
return (control: AbstractControl) => {
if (!control.value || control.value.length < 3) return of(null);
return control.valueChanges.pipe(
debounceTime(500),
distinctUntilChanged(),
switchMap(title =>
inject(TaskService).checkTitleExists(title, this.existingTask?._id)
),
map(exists => exists ? { duplicateTitle: true } : null),
first(), // complete after first check to avoid infinite loop
);
};
}
// ── Form submission ───────────────────────────────────────────────────
onSubmit(): void {
// Mark all as touched to show all validation errors at once
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.submitError.set(null);
const value = this.form.getRawValue();
const dto = {
title: value.title,
description: value.description || undefined,
priority: value.priority,
dueDate: value.dueDate ? new Date(value.dueDate).toISOString() : undefined,
tags: value.tags.filter(Boolean),
};
const action$ = this.isEditing()
? this.taskStore.update(this.existingTask!._id, dto)
: this.taskStore.create(dto);
action$.subscribe({
next: task => {
this.form.markAsPristine(); // reset dirty state after save
this.submitting.set(false);
this.router.navigate(['/tasks', task._id]);
},
error: err => {
this.submitError.set(err.message);
this.submitting.set(false);
},
});
}
onReset(): void {
if (this.existingTask) {
this.form.reset();
this.ngOnInit(); // re-patch with original values
} else {
this.form.reset();
while (this.tagsArray.length) this.tagsArray.removeAt(0);
}
}
}
<!-- task-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="task-form" novalidate>
<!-- Title -->
<div class="field" [class.field--error]="titleCtrl.invalid && titleCtrl.touched">
<label for="title">Title *</label>
<input id="title" type="text" formControlName="title"
placeholder="Task title"
[attr.aria-invalid]="titleCtrl.invalid && titleCtrl.touched">
<div class="field__errors" *ngIf="titleCtrl.invalid && titleCtrl.touched">
<p *ngIf="titleCtrl.errors?.['required']">Title is required.</p>
<p *ngIf="titleCtrl.errors?.['minlength']">
Title must be at least {{ titleCtrl.errors?.['minlength'].requiredLength }} characters.
</p>
<p *ngIf="titleCtrl.errors?.['maxlength']">Title is too long.</p>
</div>
</div>
<!-- Priority -->
<div class="field">
<label for="priority">Priority *</label>
<select id="priority" formControlName="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<!-- Due date with custom validator error -->
<div class="field" [class.field--error]="dueDateCtrl.invalid && dueDateCtrl.touched">
<label for="dueDate">Due Date {{ priorityCtrl.value === 'high' ? '*' : '' }}</label>
<input id="dueDate" type="date" formControlName="dueDate">
<div class="field__errors" *ngIf="dueDateCtrl.invalid && dueDateCtrl.touched">
<p *ngIf="dueDateCtrl.errors?.['required']">Due date is required for high priority tasks.</p>
<p *ngIf="dueDateCtrl.errors?.['pastDate']">Due date must be in the future.</p>
</div>
</div>
<!-- Dynamic tags FormArray -->
<div class="field" formArrayName="tags">
<label>Tags</label>
<div *ngFor="let tagCtrl of tagsArray.controls; let i = index"
class="tag-input">
<input [formControlName]="i" placeholder="Tag name">
<button type="button" (click)="removeTag(i)">×</button>
</div>
<button type="button" (click)="addTag()"
[disabled]="tagsArray.length >= 10">
+ Add Tag
</button>
</div>
<!-- Nested group -->
<div formGroupName="assignee" class="field">
<input formControlName="userId" type="hidden">
<label for="assigneeName">Assignee</label>
<input id="assigneeName" formControlName="name" placeholder="Assignee name">
</div>
<!-- Global error -->
<p *ngIf="submitError()" class="error">{{ submitError() }}</p>
<!-- Actions -->
<footer class="form-actions">
<button type="button" (click)="onReset()" [disabled]="submitting()">Reset</button>
<button type="submit" [disabled]="submitting() || form.invalid">
{{ submitting() ? 'Saving...' : (isEditing() ? 'Update Task' : 'Create Task') }}
</button>
</footer>
</form>
How It Works
Step 1 — FormGroup Binds to the Form Element
[formGroup]="form" on the <form> element establishes a connection between the DOM form and the TypeScript FormGroup. Angular’s FormGroupDirective provides the form group context for all descendant formControlName, formGroupName, and formArrayName directives. The directive also intercepts the form’s native submit event, preventing full-page reloads and routing it to your (ngSubmit) handler.
Step 2 — formControlName Binds Individual Controls
formControlName="title" on an input element creates a two-way binding between the HTML input and the title control in the parent FormGroup. Value changes in the input update the FormControl’s value; programmatic updates to the FormControl (via setValue(), patchValue(), or reset())) update the input’s displayed value. The directive also sets the input’s valid/invalid CSS classes and exposes validation state to Angular’s forms API.
Step 3 — FormArray Manages Dynamic Lists of Controls
FormArray holds an ordered list of AbstractControl objects. Unlike FormGroup (which has named keys), a FormArray uses numeric indices. Adding a control with tagsArray.push(new FormControl(''))) appends to the end; removeAt(i) removes by index; at(i) gets the control. The template iterates the array’s controls with *ngFor and uses the index as the formControlName: [formControlName]="i".
Step 4 — patchValue Sets Partial Form Values
form.patchValue({ title: 'My Task' }) sets only the specified fields, leaving others unchanged. form.setValue({ ... }) requires values for every control in the group — omitting any throws an error. Use patchValue() when populating a form from an existing record (you may not want to set every field), and setValue() when you have complete data and want to catch schema mismatches at development time.
Step 5 — markAllAsTouched Reveals All Validation Errors on Submit
Validation errors are only displayed when a control is “touched” — the user has blurred the input at least once. If the user clicks Submit without touching any field, all errors are computed but none are shown. form.markAllAsTouched() programmatically marks every control as touched, causing the template’s *ngIf="ctrl.invalid && ctrl.touched" conditions to evaluate to true for every invalid field, showing all errors at once — giving the user a complete picture of what needs to be fixed.
Common Mistakes
Mistake 1 — Forgetting to import ReactiveFormsModule
❌ Wrong — formControlName not recognised in standalone component:
@Component({
standalone: true,
imports: [CommonModule], // ReactiveFormsModule missing!
template: `<input formControlName="title">`
})
// Error: formControlName is not a known property of 'input'
✅ Correct — import ReactiveFormsModule:
imports: [CommonModule, ReactiveFormsModule]
Mistake 2 — Not calling markAllAsTouched before checking validity on submit
❌ Wrong — errors not shown for untouched invalid fields:
onSubmit(): void {
if (this.form.invalid) return; // returns true but no error messages shown!
// User sees form disabled but doesn't know why
}
✅ Correct — mark all touched first:
onSubmit(): void {
this.form.markAllAsTouched(); // reveal all validation errors
if (this.form.invalid) return;
// Now submit
}
Mistake 3 — Using setValue when patchValue is needed
❌ Wrong — setValue requires ALL form fields:
// Form has 10 fields but API returns only 5
this.form.setValue({ title: task.title, status: task.status });
// Error: Must supply a value for form control with name: 'description'
✅ Correct — use patchValue for partial updates:
this.form.patchValue({ title: task.title, status: task.status });
// Other fields unchanged — no error
Quick Reference
| Task | Code |
|---|---|
| Create form | fb.group({ title: ['', Validators.required] }) |
| Get control | form.get('title') or form.controls.title |
| Set value | form.patchValue({ title: 'Task' }) |
| Reset form | form.reset() or form.reset({ title: '' }) |
| Add to FormArray | array.push(fb.control('', Validators.required)) |
| Remove from array | array.removeAt(index) |
| Show errors | *ngIf="ctrl.invalid && ctrl.touched" |
| Specific error | ctrl.errors?.['required'] |
| Mark all touched | form.markAllAsTouched() |
| Disable control | ctrl.disable() / ctrl.enable() |
| Form value | form.value (excludes disabled) / form.getRawValue() (all) |