This guide provides strategies for migrating existing codebases to Signal Forms, focusing on interoperability with legacy 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 legacy 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 legacy 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 legacy 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 legacy validator fails
In the template, use standard reactive syntax by binding the underlying control:
<form> <div> <label> Email: <input [field]="f.email"> </label> </div> <div> <label> Password: <input [field]="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 legacy Reactive Forms.
import {signal} from '@angular/core';import {FormGroup, FormControl, Validators} from '@angular/forms';import {compatForm} from '@angular/forms/signals/compat';// 1. A legacy 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 legacy controls via .control():
<form> <h3>Shipping Details</h3> <div> <label> Customer Name: <input [field]="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 legacy 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
This is coming soon.
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 {NG_STATUS_CLASSES, provideSignalFormsConfig} from '@angular/forms/signals';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(), }, }), ],});