Most existing Angular codebases and many libraries use reactive forms, so you need to read and maintain them.
Before signal forms, the standard for complex forms was reactive forms (ReactiveFormsModule). They remain fully supported and are everywhere in existing code.
The form model is a tree of control objects created in the class:
FormControlholds one value plus validation state.FormGroupgroups named controls;FormArrayholds a dynamic list.FormBuilder(viainject(FormBuilder).nonNullable) shortens creation:fb.group({ email: ['', [Validators.required, Validators.email]] }).
Forms are strictly typed since Angular 14: form.value.email is string | undefined (disabled controls are excluded from value), while form.getRawValue() includes everything. nonNullable makes reset() return to the initial value instead of null.
The template binds with directives: [formGroup]="form" on the <form>, formControlName="email" on inputs, formGroupName/formArrayName for nesting, and (ngSubmit) for submission.
Validation: built-in Validators (required, email, minLength, pattern...), custom ValidatorFns returning an error object or null, async validators, and group-level validators for cross-field rules. Check control.hasError('required'), invalid, touched and dirty.
Changes are exposed as Observables: valueChanges and statusChanges, often combined with RxJS or converted with toSignal. Custom inputs integrate by implementing ControlValueAccessor.
Example
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup', imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="email" />
@if (form.controls.email.touched && form.controls.email.hasError('email')) { <small>Invalid email</small> }
<div formGroupName="address"><input formControlName="city" /></div>
<button [disabled]="form.invalid">Sign up</button>
</form>`,
})
export class Signup {
private fb = inject(FormBuilder).nonNullable;
protected form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
address: this.fb.group({ city: ['', Validators.required] }),
});
constructor() { this.form.controls.email.valueChanges.subscribe((v) => console.log('email:', v)); }
save() {
console.log(this.form.getRawValue()); // { email: string; address: { city: string } }
this.form.reset(); // back to '' thanks to nonNullable
}
}Remember
FormGroup/FormControl/FormArraymodel the form in the class; templates bind withformControlName- Forms are typed;
nonNullablemakes reset restore initial values valueChanges/statusChangesare Observables; validators return error objects or null
Watch out for
- Reading
form.valueand missing disabled controls; usegetRawValue()when you need every field.