Reactive Forms Basics
Reactive forms give you a model-driven approach where the form’s structure and state live in TypeScript rather than in the template. You build an explicit tree of FormControl and FormGroup objects, bind them to the DOM, and read or react to their state as synchronous values and observable streams. This makes reactive forms predictable, strongly typed, and easy to unit test — which is why they’re the preferred choice for anything beyond a trivial input.
Why reactive forms
With template-driven forms, Angular builds the form model implicitly behind the scenes from directives like ngModel. Reactive forms invert that: you own the model. Because the source of truth is a plain object graph in your component, you can inspect it, mutate it, validate it, and test it without rendering a single template.
| Aspect | Reactive forms | Template-driven forms |
|---|---|---|
| Source of truth | Component class (explicit) | Template (implicit) |
| Data flow | Synchronous | Asynchronous |
| Validation | Functions in code | Directives in template |
| Scales to complex forms | Excellent | Becomes awkward |
| Testability | High (no DOM needed) | Requires DOM |
Enabling reactive forms
Reactive forms live in ReactiveFormsModule. In a standalone component, import it directly into the component’s imports array — there’s no NgModule to register.
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormControl } from '@angular/forms';
@Component({
selector: 'app-newsletter',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './newsletter.component.html',
})
export class NewsletterComponent {
email = new FormControl('');
}
A single FormControl
FormControl tracks the value and validation status of one input. You bind it to an element with the formControl directive, and Angular keeps the two in sync in both directions.
<label for="email">Email</label>
<input id="email" type="email" [formControl]="email" />
<p>Current value: {{ email.value }}</p>
@if (email.dirty) {
<p>You changed this field.</p>
}
The constructor’s first argument is the initial value. Reading email.value returns it synchronously at any time, and the control exposes status flags like dirty, touched, valid, and pristine that update as the user interacts with the input.
The
formControldirective (singular) binds one control. Don’t confuse it withformControlName(used inside aFormGroup) — mixing them up is the most common reactive-forms error.
Grouping with FormGroup
Real forms have several fields. FormGroup aggregates a collection of named controls into a single object whose value is a keyed record. Its value, validity, and dirty state are derived from its children.
import { Component } from '@angular/core';
import { ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-profile',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile.component.html',
})
export class ProfileComponent {
profileForm = new FormGroup({
firstName: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
lastName: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
});
onSubmit(): void {
if (this.profileForm.valid) {
console.log('Submitting', this.profileForm.value);
}
}
}
In the template, bind the group with formGroup and each control by name with formControlName. Use the ngSubmit event on the <form> so Angular handles the submit cleanly.
<form [formGroup]="profileForm" (ngSubmit)="onSubmit()">
<input formControlName="firstName" placeholder="First name" />
<input formControlName="lastName" placeholder="Last name" />
<input formControlName="email" type="email" placeholder="Email" />
@if (profileForm.get('email')?.hasError('email') && profileForm.get('email')?.touched) {
<p class="error">Enter a valid email address.</p>
}
<button type="submit" [disabled]="profileForm.invalid">Save</button>
</form>
Submitting with a valid form logs the aggregated value:
Output:
Submitting { firstName: 'Ada', lastName: 'Lovelace', email: '[email protected]' }
Reading and reacting to state
Because value is synchronous, you can read the whole form at any moment. For reactions, every control and group exposes valueChanges and statusChanges as observables — ideal for live previews, autosave, or dependent fields.
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormGroup, FormControl } from '@angular/forms';
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `<input [formControl]="query" placeholder="Search..." />`,
})
export class SearchComponent {
private destroyRef = inject(DestroyRef);
query = new FormControl('', { nonNullable: true });
constructor() {
this.query.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((term) => console.log('Searching for:', term));
}
}
Updating values from code
You control the model imperatively too. Use setValue to replace every field (it errors if any key is missing or extra) and patchValue to update a subset. reset returns the form to its initial state and clears the dirty/touched flags.
// Replace the entire form — all keys required
this.profileForm.setValue({ firstName: 'Grace', lastName: 'Hopper', email: '[email protected]' });
// Update only some fields
this.profileForm.patchValue({ email: '[email protected]' });
// Back to pristine, empty state
this.profileForm.reset();
Prefer
getRawValue()overvaluewhen your form has disabled controls —valuesilently omits disabled fields, whilegetRawValue()returns them all.
Best practices
- Import
ReactiveFormsModuledirectly into standalone components that need it; keep imports local rather than global. - Use
nonNullable: trueon controls soreset()returns the initial value instead ofnull, and so the inferred type stays non-nullable. - Use
formControlfor lone inputs andformGroup+formControlNamefor grouped fields — never mix the two binding styles. - Drive validation messages off
touched/dirtyso users aren’t scolded before they’ve typed anything. - Unsubscribe from
valueChangeswithtakeUntilDestroyed(or theasyncpipe) to avoid memory leaks. - For forms with many fields, reach for
FormBuilderto cut down on boilerplate, and adopt typed forms for end-to-end type safety.