Build forms with FormGroup and FormControl, the API that most existing Angular applications use and that still works today.

Bishal Biswas
Self Learner
The form object comes first
Reactive forms build a form model in the class, then bind the template to it.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email">
<input formControlName="password" type="password">
<button [disabled]="form.invalid">Sign in</button>
</form>
`,
})
export class Login {
private readonly fb = inject(FormBuilder);
protected readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
protected submit(): void {
if (this.form.invalid) return;
console.log(this.form.value);
}
}
ReactiveFormsModule must be imported. It is one of the NgModules still in Angular itself.
Signal Forms are the newer approach and are stable in Angular 22. Reactive forms are not deprecated, they work fine, and the overwhelming majority of existing Angular code uses them.
The building blocks
new FormControl('initial value') // one field
new FormGroup({ ... }) // an object of controls
new FormArray([ ... ]) // a list of controls
new FormRecord({ ... }) // a group with dynamic keys
FormBuilder is shorthand for the same thing:
// verbose
protected readonly form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
password: new FormControl('', { nonNullable: true }),
});
// with FormBuilder
protected readonly form = this.fb.group({
email: ['', Validators.required],
password: [''],
});
// nonNullable by default
private readonly fb = inject(NonNullableFormBuilder);
The array shorthand is [initialValue, validators, asyncValidators].
nonNullable matters
By default, calling reset() sets a control to null, so the type is string | null.
const control = new FormControl('hello');
control.reset();
console.log(control.value); // null, and the type says so
const better = new FormControl('hello', { nonNullable: true });
better.reset();
console.log(better.value); // 'hello', the initial value
NonNullableFormBuilder applies it everywhere:
private readonly fb = inject(NonNullableFormBuilder);
protected readonly form = this.fb.group({
email: ['', Validators.required], // typed string, not string | null
});
Use it. The | null on every field otherwise spreads through your whole codebase.
Typed forms
Since Angular 14, forms are typed from their definition.
protected readonly form = this.fb.group({
name: [''],
age: [0],
});
const value = this.form.value;
// { name?: string; age?: number }
const raw = this.form.getRawValue();
// { name: string; age: number }
value omits disabled controls, so every property is optional. getRawValue() includes them and is fully typed, which is usually what you want when submitting.
Accessing controls is typed too:
this.form.controls.name.setValue('Asha'); // typed
this.form.get('name')?.setValue('Asha'); // works, but returns AbstractControl | null
Prefer controls.name over get('name'). The string version is not checked, so a typo compiles.
Reading state
this.form.value;
this.form.getRawValue();
this.form.valid;
this.form.invalid;
this.form.dirty; // has been changed
this.form.pristine;
this.form.touched; // has been blurred
this.form.pending; // async validation running
this.form.errors;
this.form.status; // 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'
Same properties on each control:
this.form.controls.email.hasError('required');
this.form.controls.email.errors;
this.form.controls.email.touched;
Showing errors
<input formControlName="email" type="email">
@if (form.controls.email.touched && form.controls.email.invalid) {
@if (form.controls.email.hasError('required')) {
<p class="error">Email is required</p>
}
@if (form.controls.email.hasError('email')) {
<p class="error">That is not a valid email address</p>
}
}
Verbose, and it is one of the reasons signal forms exist. A common tidy up is a small component:
@Component({
selector: 'app-field-errors',
template: `
@if (control().touched && control().invalid) {
@for (key of errorKeys(); track key) {
<p class="error">{{ messages[key] ?? key }}</p>
}
}
`,
})
export class FieldErrors {
readonly control = input.required<AbstractControl>();
protected readonly errorKeys = computed(() => Object.keys(this.control().errors ?? {}));
protected readonly messages: Record<string, string> = {
required: 'This field is required',
email: 'Enter a valid email address',
minlength: 'Too short',
};
}
Updating values
this.form.setValue({ email: 'a@b.com', password: 'secret123' }); // every field required
this.form.patchValue({ email: 'a@b.com' }); // partial
this.form.controls.email.setValue('a@b.com');
this.form.reset();
this.form.reset({ email: 'default@example.com', password: '' });
setValue throws if you miss a field, which catches mistakes. patchValue ignores missing and unknown keys silently, which is convenient and hides typos.
Loading a record into a form:
protected load(user: User): void {
this.form.patchValue({ name: user.name, email: user.email });
this.form.markAsPristine();
}
markAsPristine() matters. Without it the form is dirty immediately, so an unsaved changes guard fires on a form nobody edited.
Enabling and disabling
this.form.controls.email.disable();
this.form.controls.email.enable();
this.form.disable();
this.form.controls.email.disable({ emitEvent: false });
A disabled control is excluded from value and from validation. That surprises people: a disabled field vanishes from the submitted object unless you use getRawValue().
Do not set the disabled attribute in the template on a formControlName input. Angular warns about it, because the two mechanisms fight.
<!-- wrong -->
<input formControlName="email" [disabled]="busy()">
<!-- right -->
constructor() {
effect(() => {
const email = this.form.controls.email;
this.busy() ? email.disable() : email.enable();
});
}
Watching for changes
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(value => console.log(value));
this.form.controls.country.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(country => this.loadRegions(country));
this.form.statusChanges
.pipe(takeUntilDestroyed())
.subscribe(status => console.log(status));
}
valueChanges emits on every keystroke. Debounce anything expensive:
this.form.controls.search.valueChanges
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.search(term)),
takeUntilDestroyed(),
)
.subscribe(results => this.results.set(results));
Watch for loops: setting a value inside a valueChanges handler triggers it again. Use { emitEvent: false }:
this.form.controls.slug.setValue(slugify(title), { emitEvent: false });
Bridging to signals
protected readonly formValue = toSignal(this.form.valueChanges, {
initialValue: this.form.getRawValue(),
});
protected readonly isValid = toSignal(
this.form.statusChanges.pipe(map(s => s === 'VALID')),
{ initialValue: this.form.valid },
);
This is how reactive forms fit into a signal based component. It works, and it is more ceremony than signal forms need, which is the honest comparison.
Nested groups
protected readonly form = this.fb.group({
name: [''],
address: this.fb.group({
line1: [''],
city: [''],
postcode: [''],
}),
});
<input formControlName="name">
<div formGroupName="address">
<input formControlName="line1">
<input formControlName="city">
</div>
formGroupName on the wrapper, then plain formControlName inside.
Quick recap
- Reactive forms build the model in the class and bind the template to it.
ReactiveFormsModulemust be imported, and it is still an NgModule.- Use
NonNullableFormBuilderor every field is typed| null. valueomits disabled controls,getRawValue()includes them.- Prefer
form.controls.nameoverform.get('name'), which is unchecked. - Call
markAsPristine()after loading data, or unsaved change guards misfire. - Disable in the class, never with the
disabledattribute in the template.
import { Component, inject, effect, signal, computed } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators,
AbstractControl } from '@angular/forms';
import { toSignal, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';
@Component({
selector: 'app-user-form',
imports: [ReactiveFormsModule], // still an NgModule, must be imported
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email">
<!-- do NOT also set [disabled] here: the two mechanisms fight -->
@if (form.controls.email.touched && form.controls.email.invalid) {
@if (form.controls.email.hasError('required')) { <p>Email is required</p> }
@if (form.controls.email.hasError('email')) { <p>Not a valid email</p> }
}
<div formGroupName="address">
<input formControlName="city">
</div>
<button [disabled]="form.invalid">Save</button>
</form>
`,
})
export class UserForm {
// NonNullableFormBuilder, or reset() sets null and every field is `| null`
private readonly fb = inject(NonNullableFormBuilder);
protected readonly busy = signal(false);
protected readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
address: this.fb.group({ city: [''] }),
});
constructor() {
// valueChanges fires on EVERY keystroke. Debounce anything expensive.
this.form.controls.email.valueChanges
.pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed())
.subscribe(v => console.log(v));
// Disable in the CLASS, not with a template attribute
effect(() => this.busy()
? this.form.controls.email.disable()
: this.form.controls.email.enable());
}
protected load(user: { email: string }): void {
this.form.patchValue({ email: user.email });
// Without this the form is dirty immediately and unsaved change
// guards fire on a form nobody edited.
this.form.markAsPristine();
}
protected submit(): void {
if (this.form.invalid) return;
// value OMITS disabled controls and types every field optional.
// getRawValue() includes them and is fully typed.
console.log(this.form.getRawValue());
}
// Setting a value inside a valueChanges handler loops. Suppress the event:
protected syncSlug(title: string): void {
this.form.controls.email.setValue(title, { emitEvent: false });
}
// Bridging into a signal based component
protected readonly isValid = toSignal(
this.form.statusChanges.pipe(map(s => s === 'VALID')),
{ initialValue: this.form.valid });
}
// form.controls.name is TYPED. form.get('name') is an unchecked string
// lookup returning AbstractControl | null. Prefer controls.
// setValue requires EVERY field. patchValue ignores missing and unknown
// keys silently, which is convenient and hides typos.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready