In-depth Guides
Forms

Migrating existing forms to Signal Forms

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(...) updates emailControl.value and the parent form.value.
  • Control -> Signal: Typing in the input (updating emailControl) updates the email signal.
  • Validation: Schema validators (like required) propagate errors to emailControl.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.

Avoid
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:

Prefer
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.

Avoid
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:

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