Reactive Forms are still fully supported in v22 and remain the most widely used forms in existing applications. Since v14 they are typed. If you have a project with Reactive Forms, you don’t have to migrate everything — Angular 22 has bridges (SignalFormControl, compatForm) to combine the two worlds.
Typed forms
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
// The types are inferred from the initial values
const profile = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
age: new FormControl<number | null>(null),
});
profile.value; // Partial<{ name: string; age: number | null }> — without the disabled controls
profile.getRawValue(); // { name: string; age: number | null } — with everything
profile.controls.name.setValue(42); // ✗ compile error
// FormBuilder.nonNullable: every control goes back to its initial value on reset()
private readonly fb = inject(FormBuilder).nonNullable;
readonly form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
skills: this.fb.array(['Angular']),
});getRawValue(): {
"name": "",
"email": "",
"security": {
"password": "",
"confirm": ""
},
"skills": [
"Angular",
"TypeScript"
]
}Validators
// Custom validator (pure function)
export const noWhitespace: ValidatorFn = (c) => (c.value?.trim() ? null : { whitespace: true });
// Group validator (cross-field)
export const matchingPasswords: ValidatorFn = (group) =>
group.get('password')?.value !== group.get('confirm')?.value ? { mismatch: true } : null;
this.fb.group({ password: [''], confirm: [''] }, { validators: matchingPasswords });
// Async validator
export function usernameAvailable(api: UsersApi): AsyncValidatorFn {
return (c) => timer(400).pipe(switchMap(() => api.isAvailable(c.value)), map((ok) => (ok ? null : { taken: true })));
}Reactive Forms ↔ signals
// Reactive Forms + signals: toSignal over Observables
readonly value = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() });
readonly status = toSignal(this.form.statusChanges, { initialValue: this.form.status });
readonly canSave = computed(() => this.status() === 'VALID');
// v18+: a single stream with all events (value, status, touched, pristine, submit, reset)
this.form.events.pipe(takeUntilDestroyed()).subscribe((e) => {
if (e instanceof FormSubmittedEvent) this.analytics.track('submit');
});Gradual migration to Signal Forms v22
// Gradual migration (v22): two directions
// 1. Bottom-up — a new field with Signal Forms rules inside an existing FormGroup
import { SignalFormControl } from '@angular/forms/signals/compat';
const form = new FormGroup({
name: new SignalFormControl('Ana', (p) => { required(p); }),
age: new FormControl(25),
});
// template: <input [formField]="nameControl.fieldTree" /> <input formControlName="age" />
// 2. Top-down — a signal form that contains existing reactive controls
import { compatForm } from '@angular/forms/signals/compat';
const lastName = new FormControl('Pop');
const model = signal({ first: '', last: lastName });
const nameForm = compatForm(model, (p) => { required(p.first); });
nameForm.last().value(); // 'Pop' — the value, not the FormControlDynamic keys: FormRecord
FormGroup assumes you know the keys in advance. When they come from the server (a list of permissions, settings per module), use FormRecord: all values share one type, and keys can be added and removed at runtime.
// keys arrive at runtime; every value is a boolean
readonly permissions = new FormRecord<FormControl<boolean>>({});
constructor() {
for (const p of ['read', 'write', 'delete']) {
this.permissions.addControl(p, new FormControl(false, { nonNullable: true }));
}
}
// the value is an object with the added keys
// this.permissions.value → { read: false, write: true, delete: false }Async validators and updateOn
An async validator returns an Observable or a Promise with the errors. While it runs, the control has status PENDING and the form is neither valid nor invalid. Set updateOn: 'blur' so you do not send a request on every keystroke.
export function usernameFree(api: UsersApi): AsyncValidatorFn {
return (control) =>
api.exists(control.value).pipe(
map((taken) => (taken ? { taken: true } : null)),
catchError(() => of(null)), // server down? do not block the form
);
}
readonly username = new FormControl('', {
nonNullable: true,
validators: [Validators.required],
asyncValidators: [usernameFree(inject(UsersApi))],
updateOn: 'blur', // validate when leaving the field, not on every keystroke
});
// template: @if (username.pending) { checking… }One stream for everything: events
Instead of combining valueChanges, statusChanges and your own signals for touched or submit, subscribe to events. You get typed events for value, status, touched, pristine, submit and reset. Tell them apart with instanceof.
import { FormSubmittedEvent, StatusChangeEvent, TouchedChangeEvent, ValueChangeEvent } from '@angular/forms';
this.form.events.pipe(takeUntilDestroyed()).subscribe((e) => {
if (e instanceof ValueChangeEvent) this.saveDraft(e.value);
if (e instanceof StatusChangeEvent) this.status.set(e.status);
if (e instanceof TouchedChangeEvent) this.touched.set(e.touched);
if (e instanceof FormSubmittedEvent) this.track('submit'); // and FormResetEvent, PristineChangeEvent
});Reactive or Signal Forms?
- An existing project with forms that work — don’t rewrite them without a reason.
- UI libraries that only support
formControlName/ CVA. - Complex flows built on
valueChangesand RxJS operators.
- New forms in v22.
- Lots of conditional logic (hidden / disabled / required when…).
- You want the model to be a simple signal, without
patchValue.
The new approach is Signal Forms — the basics; a custom control that works in both worlds is covered in Custom form controls.
Common pitfalls
form.valueexcludes disabled controls — usegetRawValue()when you send to the server.- Without
nonNullable,reset()setsnull→ the type becomesstring | null. valueChangessubscriptions without cleanup → a leak. UsetoSignalortakeUntilDestroyed.- Template-driven (ngModel) + Reactive on the same element → conflicts. Pick one.
Cheat sheet
new FormControl('', { nonNullable: true }) // reset() goes back to the initial value, not null
new FormGroup({ name: ..., email: ... }) // fixed keys
new FormArray<FormControl<string>>([]) // a list of controls
new FormRecord<FormControl<boolean>>({}) // dynamic keys, one type
form.getRawValue() // also includes disabled controls
control.setValue(v) / patchValue(partial) / reset()
control.pending control.dirty control.touched
asyncValidators: [...] updateOn: 'blur' // server validation, on blur
form.events // value, status, touched, pristine, submit, reset
toSignal(control.valueChanges, { initialValue: control.value })