This guide provides strategies for migrating existing codebases to Signal Forms, focusing on interoperability with existing Reactive Forms.
Top-down migration using compatForm
Sometimes you may want to use existing reactive FormControl instances within a Signal Form. This is useful for
controls that involve:
- Complex asynchronous logic.
- Intricate RxJS operators that are not yet ported.
- Integration with existing third-party libraries.
Integrating a FormControl into a signal form
Consider an existing passwordControl that uses a specialized enterprisePasswordValidator. Instead of rewriting the
validator, you can bridge the control into your signal state.
We can do it using compatForm:
import {signal} from '@angular/core';import {FormControl, Validators} from '@angular/forms';import {compatForm} from '@angular/forms/signals/compat';// 1. Existing control with a specialized validatorconst passwordControl = new FormControl('', { validators: [Validators.required, enterprisePasswordValidator()], nonNullable: true,});// 2. Wrap it inside your form state signalconst user = signal({ email: '', password: passwordControl, // Nest the existing control directly});// 3. Create the formconst f = compatForm(user);// Access values via the signal treeconsole.log(f.email().value()); // Current email valueconsole.log(f.password().value()); // Current value of passwordControl// Reactive state is proxied automaticallyconst isPasswordValid = f.password().valid();const passwordErrors = f.password().errors(); // Returns CompatValidationError if the existing validator fails
In the template, use standard reactive syntax by binding the underlying control:
<form novalidate> <div> <label> Email: <input [formField]="f.email" /> </label> </div> <div> <label> Password: <input [formField]="f.password" type="password" /> </label> @if (f.password().touched() && f.password().invalid()) { <div class="error-list"> @for (error of f.password().errors(); track error) { <p>{{ error.message || error.kind }}</p> } </div> } </div></form>
Integrating a FormGroup into a signal form
You can also wrap an entire FormGroup. This is common when a reusable sub-section of a form—such as an
Address Block—is still managed by existing Reactive Forms.
import {signal} from '@angular/core';import {FormGroup, FormControl, Validators} from '@angular/forms';import {compatForm} from '@angular/forms/signals/compat';// 1. An existing address group with its own validation logicconst addressGroup = new FormGroup({ street: new FormControl('123 Angular Way', Validators.required), city: new FormControl('Mountain View', Validators.required), zip: new FormControl('94043', Validators.required),});// 2. Include it in the state like it's a valueconst checkoutModel = signal({ customerName: 'Pirojok the Cat', shippingAddress: addressGroup,});const f = compatForm(checkoutModel, (p) => { required(p.customerName);});
The shippingAddress field acts as a branch in your Signal Form tree. You can bind these nested controls in your
template by accessing the underlying existing controls via .control():
<form novalidate> <h3>Shipping Details</h3> <div> <label> Customer Name: <input [formField]="f.customerName" /> </label> @if (f.customerName().touched() && f.customerName().invalid()) { <div class="error-list"> <p>Customer name is required.</p> </div> } </div> <fieldset> <legend>Address</legend> @let street = f.shippingAddress().control().controls.street; <div> <label> Street: <input [formControl]="street" /> </label> @if (street.touched && street.invalid) { <div class="error-list"> <p>Street is required</p> </div> } </div> @let city = f.shippingAddress().control().controls.city; <div> <label> City: <input [formControl]="city" /> </label> @if (city.touched && city.invalid) { <div class="error-list"> <p>City is required</p> </div> } </div> @let zip = f.shippingAddress().control().controls.zip; <div> <label> Zip Code: <input [formControl]="zip" /> </label> @if (zip.touched && zip.invalid) { <div class="error-list"> <p>Zip Code is required</p> </div> } </div> </fieldset></form>
Accessing values
While compatForm proxies value access on the FormControl level, the full form value preserves the control:
const passwordControl = new FormControl('password' /** ... */);const user = signal({ email: '', password: passwordControl, // Nest the existing control directly});const form = compatForm(user);form.password().value(); // 'password'form().value(); // { email: '', password: FormControl}
If you need the whole form value, you'd have to build it manually:
const formValue = computed(() => ({ email: form.email().value(), password: form.password().value(),})); // {email: '', password: ''}
Bottom-up migration
Integrating a Signal Form into a FormGroup
You can use SignalFormControl to expose a signal-based form as a standard FormControl. This is useful when you want
to migrate leaf nodes of a form to Signals while keeping the parent FormGroup structure.
import {Component, signal} from '@angular/core';import {ReactiveFormsModule, FormGroup} from '@angular/forms';import {SignalFormControl} from '@angular/forms/signals/compat';import {required} from '@angular/forms/signals';@Component({ // ... imports: [ReactiveFormsModule],})export class UserProfile { // 1. Create a SignalFormControl, use signal form rules. emailControl = new SignalFormControl('', (p) => { required(p, {message: 'Email is required'}); }); // 2. Use it in an existing FormGroup form = new FormGroup({ email: this.emailControl, });}
The SignalFormControl synchronizes values and validation status bi-directionally:
- Signal -> Control: Changing
email.set(...)updatesemailControl.valueand the parentform.value. - Control -> Signal: Typing in the input (updating
emailControl) updates theemailsignal. - Validation: Schema validators (like
required) propagate errors toemailControl.errors.
Disabling/Enabling control.
Imperative APIs for changing the enabled/disabled state (like enable(), disable()) are intentionally not supported
in SignalFormControl. This is because the state of the control should be derived from the signal state and rules.
Attempting to call disable/enable would throw an error.
import {signal, effect} from '@angular/core';export class UserProfile { readonly emailControl = new SignalFormControl(''); readonly isLoading = signal(false); constructor() { // This will throw an error effect(() => { if (this.isLoading()) { this.emailControl.disable(); } else { this.emailControl.enable(); } }); }}
Instead, use disabled rule:
import {signal} from '@angular/core';import {SignalFormControl} from '@angular/forms/signals/compat';import {disabled} from '@angular/forms/signals';export class UserProfile { readonly isLoading = signal(false); readonly emailControl = new SignalFormControl('', (p) => { // The control becomes disabled whenever isLoading is true disabled(p, () => this.isLoading()); }); async saveData() { this.isLoading.set(true); // ... perform save ... this.isLoading.set(false); }}
Dynamic manipulation
Imperative APIs for adding or removing validators (like addValidators(), removeValidators(), setValidators()) are
intentionally not supported in SignalFormControl.
Attempting to call these methods will throw an error.
export class UserProfile { readonly emailControl = new SignalFormControl(''); readonly isRequired = signal(false); toggleRequired() { this.isRequired.update((v) => !v); // This will throw an error if (this.isRequired()) { this.emailControl.addValidators(Validators.required); } else { this.emailControl.removeValidators(Validators.required); } }}
Instead, use applyWhen rule to conditionally apply validators:
import {signal} from '@angular/core';import {SignalFormControl} from '@angular/forms/signals/compat';import {applyWhen, required} from '@angular/forms/signals';export class UserProfile { readonly isRequired = signal(false); readonly emailControl = new SignalFormControl('', (p) => { // The control becomes required whenever isRequired is true applyWhen( p, () => this.isRequired(), (p) => { required(p); }, ); });}
Manual Error Selection
The setErrors() and markAsPending() methods are not supported. In Signal Forms, errors are derived from validation
rules and async validation status. If you need to report an error, it should be done declaratively via a validation rule
in the schema.
Automatic status classes
Reactive/Template Forms automatically adds class attributes (
such as .ng-valid or .ng-dirty) to facilitate styling control states. Signal Forms does not do that.
If you want to preserve this behavior, you can provide the NG_STATUS_CLASSES preset:
import {provideSignalFormsConfig} from '@angular/forms/signals';import {NG_STATUS_CLASSES} from '@angular/forms/signals/compat';bootstrapApplication(App, { providers: [ provideSignalFormsConfig({ classes: NG_STATUS_CLASSES, }), ],});
You can also provide your own custom configuration to apply whatever classes you wish based on you custom logic:
import {provideSignalFormsConfig} from '@angular/forms/signals';bootstrapApplication(App, { providers: [ provideSignalFormsConfig({ classes: { 'ng-valid': ({state}) => state().valid(), 'ng-invalid': ({state}) => state().invalid(), 'ng-touched': ({state}) => state().touched(), 'ng-dirty': ({state}) => state().dirty(), }, }), ],});