In-depth Guides
Forms
Signal forms

Migrating existing forms to Signal Forms

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(),      },    }),  ],});