Skip to content
ToolShedby Wasim Shaikh

Signal Forms Migration Reference

Side-by-side Reactive Forms to Signal Forms reference: FormGroup, Validators, FormArray, valueChanges, ControlValueAccessor and submission, with code checked against angular.dev.

Showing 34 of 34 mappings

ReactiveFormsModule

FormFieldSetup
Reactive Forms
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [ReactiveFormsModule],
})
Signal Forms
import { form, FormField, required } from '@angular/forms/signals';

@Component({
  imports: [FormField],
})

Everything comes from @angular/forms/signals. Import the FormField directive in each component that binds inputs.

Source: Signal Forms overview (angular.dev)

FormGroup / FormControl

signal() + form()Setup
Reactive Forms
loginForm = new FormGroup({
  email: new FormControl('', { nonNullable: true }),
  password: new FormControl('', { nonNullable: true }),
});
Signal Forms
loginModel = signal({
  email: '',
  password: '',
});

loginForm = form(this.loginModel);

Your own writable signal is the source of truth. form() builds a field tree that mirrors its shape, and types are inferred from the model.

Source: Form models (angular.dev)

FormBuilder / fb.group()

form(model)Setup
Reactive Forms
private fb = inject(FormBuilder);

profileForm = this.fb.nonNullable.group({
  name: [''],
  email: [''],
});
Signal Forms
profileModel = signal({ name: '', email: '' });

profileForm = form(this.profileModel);

There is no builder: a plain object in a signal replaces the group definition.

Source: Form models (angular.dev)

formControlName / [formControl]

[formField]Setup
Reactive Forms
<form [formGroup]="loginForm">
  <input type="email" formControlName="email" />
  <input type="password" formControlName="password" />
</form>
Signal Forms
<input type="email" [formField]="loginForm.email" />
<input type="password" [formField]="loginForm.password" />

Bind each input to its field in the tree. There is no [formGroup] wrapper; use [formRoot] on a <form> only if you want submission handling (see Submission).

Source: Form models (angular.dev)

form.value / getRawValue()

model() / field().value()Values
Reactive Forms
const data = this.loginForm.getRawValue();
const email = this.loginForm.controls.email.value;
Signal Forms
const data = this.loginModel();
const email = this.loginForm.email().value();

Calling a field (loginForm.email()) returns its FieldState; value is a writable signal on it.

Source: Form models (angular.dev)

setValue() / patchValue()

model.set() / value.set()Values
Reactive Forms
this.userForm.setValue({ name: 'Alice', email: 'alice@example.com' });
this.userForm.patchValue({ email: '' });
Signal Forms
this.userModel.set({ name: 'Alice', email: 'alice@example.com' });
this.userForm.email().value.set('');
this.userForm.age().value.update((age) => age + 1);

Replace the whole model with set(), or write one field through its value signal.

Source: Form models (angular.dev)

valueChanges.subscribe()

computed() / effect()Values
Reactive Forms
this.form.controls.email.valueChanges
  .pipe(takeUntilDestroyed())
  .subscribe((email) => this.preview = email.toLowerCase());
Signal Forms
preview = computed(() => this.loginForm.email().value().toLowerCase());

Values are signals, so derived values are computed() and side effects are effect(). No subscriptions or unsubscribing.

Source: Form models (angular.dev)

form.get('address.city')

form.address.cityValues
Reactive Forms
const city = this.form.get('address.city');
Signal Forms
const city = this.userForm.address.city;

Fields are typed properties of the tree, so typos fail at compile time instead of returning null.

Source: Form models (angular.dev)

Validators.required / Validators.email

required() / email()Validation
Reactive Forms
email: new FormControl('', {
  validators: [Validators.required, Validators.email],
  nonNullable: true,
}),
Signal Forms
loginForm = form(this.loginModel, (schemaPath) => {
  required(schemaPath.email, { message: 'Email is required' });
  email(schemaPath.email, { message: 'Please enter a valid email address' });
});

Validation lives in the schema function passed to form(). It runs once when the form is created. Each rule can carry its own message.

Source: Validation (angular.dev)

Validators.min / max / minLength / maxLength / pattern

min() / max() / minLength() / maxLength() / pattern()Validation
Reactive Forms
age: [null, [Validators.min(18), Validators.max(120)]],
password: ['', Validators.minLength(8)],
phone: ['', Validators.pattern(/^\d{3}-\d{3}-\d{4}$/)],
Signal Forms
min(schemaPath.age, 18, { message: 'You must be at least 18 years old' });
max(schemaPath.age, 120, { message: 'Please enter a valid age' });
minLength(schemaPath.password, 8, { message: 'Password must be at least 8 characters' });
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
  message: 'Phone must be in format: 555-123-4567',
});

minLength() and maxLength() work on strings and arrays. min() and max() also accept a function for a reactive limit.

Source: Validation (angular.dev)

Custom ValidatorFn

validate()Validation
Reactive Forms
function httpsOnly(): ValidatorFn {
  return (control) =>
    control.value?.startsWith('https://') ? null : { https: true };
}
Signal Forms
validate(schemaPath.website, ({ value }) => {
  if (!value().startsWith('https://')) {
    return { kind: 'https', message: 'URL must start with https://' };
  }
  return null;
});

Return an error object with a kind (and optional message), or null when valid.

Source: Validation (angular.dev)

FormGroup validator (password match)

validate() + valueOf()Validation
Reactive Forms
const matchPasswords: ValidatorFn = (group) =>
  group.get('password')?.value === group.get('confirmPassword')?.value
    ? null
    : { passwordMismatch: true };

new FormGroup({ password, confirmPassword }, { validators: matchPasswords });
Signal Forms
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
  if (value() !== valueOf(schemaPath.password)) {
    return { kind: 'passwordMismatch', message: 'Passwords do not match' };
  }
  return null;
});

Put the rule on the field that should show the error and read other fields with valueOf(). The error lands on confirmPassword, not the group.

Source: Validation (angular.dev)

AsyncValidatorFn

validateHttp()Validation
Reactive Forms
username: ['', {
  asyncValidators: [this.usernameTaken()],
  updateOn: 'blur',
}],
Signal Forms
validateHttp(schemaPath.username, {
  request: ({ value }) => `/api/check-username?username=${value()}`,
  onSuccess: (response) =>
    response.taken ? { kind: 'usernameTaken', message: 'Already taken' } : null,
  onError: () => ({ kind: 'networkError', message: 'Could not verify' }),
});

While the request runs, the field reports pending(). Combine with debounce() to avoid a request per keystroke.

Source: Validation (angular.dev)

addValidators() / removeValidators()

rule option when / applyWhen()Validation
Reactive Forms
this.form.controls.applyDiscount.valueChanges.subscribe((on) => {
  const promo = this.form.controls.promoCode;
  on ? promo.addValidators(Validators.required) : promo.removeValidators(Validators.required);
  promo.updateValueAndValidity();
});
Signal Forms
required(schemaPath.promoCode, {
  message: 'Promo code required',
  when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});

// A group of rules:
applyWhen(schemaPath, ({ valueOf }) => valueOf(schemaPath.country) === 'US', (schemaPath) => {
  required(schemaPath.zipCode);
});

Conditions are declared once and re-evaluate automatically; no manual updateValueAndValidity().

Source: Validation (angular.dev)

Zod / Valibot schema (manual adapter)

validateStandardSchema()Validation
Reactive Forms
// No built-in support: write a ValidatorFn that runs the schema
// and maps its issues to ValidationErrors.
Signal Forms
import { validateStandardSchema } from '@angular/forms/signals';
import * as z from 'zod';

const userSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
});

userForm = form(this.userModel, (schemaPath) => {
  validateStandardSchema(schemaPath, userSchema);
});

Works with any Standard Schema library (Zod, Valibot and others).

Source: Validation (angular.dev)

hasError() / errors in the template

field().errors()State
Reactive Forms
@if (email.touched && email.hasError('required')) {
  <p>Email is required</p>
}
Signal Forms
@if (loginForm.email().touched() && loginForm.email().invalid()) {
  <ul>
    @for (error of loginForm.email().errors(); track error) {
      <li>{{ error.message }}</li>
    }
  </ul>
}

errors() is an array of { kind, message } objects, so one loop renders every message.

Source: Validation (angular.dev)

valid / invalid / touched / dirty / pending

valid() / invalid() / touched() / dirty() / pending()State
Reactive Forms
this.form.valid;
this.form.controls.email.touched;
this.form.controls.email.dirty;
this.form.pending;
Signal Forms
this.loginForm().valid();
this.loginForm.email().touched();
this.loginForm.email().dirty();
this.loginForm().pending();

Every state is a signal on the FieldState. The root form aggregates valid, invalid, pending, touched and dirty.

Source: Field state management (angular.dev)

markAllAsTouched()

markAsTouched()State
Reactive Forms
this.form.markAllAsTouched();
Signal Forms
this.loginForm().markAsTouched();

markAsTouched() marks the field and all its descendants. submit() also marks everything as touched for you.

Source: Field state management (angular.dev)

reset()

reset() + model.set()State
Reactive Forms
this.form.reset();
Signal Forms
this.profileForm().reset();
this.profileModel.set({ name: '', email: '' });

reset() clears touched and dirty. The data lives in your model signal, so set it back to the initial value too (reset() can also take new model data).

Source: Field state management (angular.dev)

.ng-valid / .ng-touched CSS classes

provideSignalFormsConfig()State
Reactive Forms
/* Added automatically by Reactive Forms */
input.ng-invalid.ng-touched { border-color: red; }
Signal Forms
import { provideSignalFormsConfig } from '@angular/forms/signals';
import { NG_STATUS_CLASSES } from '@angular/forms/signals/compat';

bootstrapApplication(App, {
  providers: [provideSignalFormsConfig({ classes: NG_STATUS_CLASSES })],
});

Signal Forms does not add ng-* classes by default. This provider restores them so existing CSS keeps working.

Source: Migrating from Reactive Forms (angular.dev)

disable() / enable()

disabled()Dynamic rules
Reactive Forms
if (this.total < 50) {
  this.form.controls.couponCode.disable();
} else {
  this.form.controls.couponCode.enable();
}
Signal Forms
disabled(schemaPath.couponCode, {
  when: ({ valueOf }) => valueOf(schemaPath.total) < 50,
});

Declare when a field is disabled instead of toggling it imperatively. Disabled fields do not affect the parent form state.

Source: Form logic (angular.dev)

Manual *ngIf / [readonly] logic

hidden() / readonly()Dynamic rules
Reactive Forms
<!-- Visibility and read-only state tracked by hand -->
@if (form.value.isPublic) {
  <input formControlName="publicUrl" />
}
Signal Forms
hidden(schemaPath.publicUrl, { when: ({ valueOf }) => !valueOf(schemaPath.isPublic) });
readonly(schemaPath.title, { when: ({ valueOf }) => valueOf(schemaPath.isLocked) });

// Template
@if (!profileForm.publicUrl().hidden()) {
  <input [formField]="profileForm.publicUrl" />
}

hidden() only sets state; you still decide how to render it. readonly() sets the HTML readonly attribute.

Source: Form logic (angular.dev)

valueChanges.pipe(debounceTime())

debounce()Dynamic rules
Reactive Forms
this.form.controls.query.valueChanges
  .pipe(debounceTime(300))
  .subscribe((q) => this.search(q));
Signal Forms
debounce(schemaPath.query, 300);

Delays model updates for that field, in milliseconds. You can also pass a function that returns a promise.

Source: Form logic (angular.dev)

Nested FormGroup / formGroupName

Nested object in the modelNesting & arrays
Reactive Forms
form = new FormGroup({
  profile: new FormGroup({ firstName: new FormControl('') }),
});

<div formGroupName="profile">
  <input formControlName="firstName" />
</div>
Signal Forms
userModel = signal({ profile: { firstName: '' } });
userForm = form(this.userModel);

<input [formField]="userForm.profile.firstName" />

The model must be made of plain objects and arrays. Nested paths bind directly; no formGroupName.

Source: Form models (angular.dev)

FormArray (push / removeAt)

Array in the model + applyEach()Nesting & arrays
Reactive Forms
items = new FormArray([this.newItem()]);

add() { this.items.push(this.newItem()); }
remove(i: number) { this.items.removeAt(i); }
Signal Forms
orderForm = form(this.orderModel, (schemaPath) => {
  applyEach(schemaPath.items, (item) => {
    required(item.name);
    min(item.quantity, 1);
  });
});

add() {
  this.orderModel.update((o) => ({ ...o, items: [...o.items, { name: '', quantity: 1 }] }));
}

// Template
@for (item of orderForm.items; track $index) {
  <input [formField]="item.name" />
}

Add or remove items by updating the model. applyEach() applies its rules to every item, including ones added later.

Source: Schemas and composition (angular.dev)

Shared validator arrays / helper functions

schema() + apply()Nesting & arrays
Reactive Forms
const nameValidators = [Validators.required];

first: ['', nameValidators],
last: ['', nameValidators],
Signal Forms
const nameSchema = schema<{ first: string; last: string }>((name) => {
  required(name.first);
  required(name.last);
});

userForm = form(this.userModel, (schemaPath) => {
  apply(schemaPath.name, nameSchema);
});

A schema bundles rules for a model shape so several forms (or array items via applyEach) can reuse it.

Source: Schemas and composition (angular.dev)

(ngSubmit) + manual valid check

[formRoot] + submission actionSubmission
Reactive Forms
<form [formGroup]="contactForm" (ngSubmit)="save()">...</form>

save() {
  if (this.contactForm.invalid) {
    this.contactForm.markAllAsTouched();
    return;
  }
  this.api.save(this.contactForm.getRawValue());
}
Signal Forms
<form [formRoot]="contactForm">...</form>

contactForm = form(this.contactModel, (schemaPath) => {
  required(schemaPath.name);
}, {
  submission: {
    action: async (field) => {
      const result = await saveContact(field().value());
      if (result.ok) return;
      return { kind: 'serverError', message: 'Failed to submit form' };
    },
  },
});

Import FormRoot. The action only runs when the form is valid, and FormRoot adds novalidate to the <form> for you.

Source: Form submission (angular.dev)

Submitting from a method

submit()Submission
Reactive Forms
async onSave() {
  if (this.form.invalid) return;
  await this.api.save(this.form.getRawValue());
}
Signal Forms
async onSave() {
  const success = await submit(this.contactForm, async (field) => {
    const result = await saveContact(field().value());
    if (result.ok) return;
    return { kind: 'serverError', message: 'Failed to save' };
  });

  if (success) {
    // navigate, show confirmation, etc.
  }
}

submit() marks all fields as touched and resolves to true when the action completed without errors.

Source: Form submission (angular.dev)

setErrors() after a failed request

Return errors from the actionSubmission
Reactive Forms
this.api.save(value).subscribe({
  error: () => this.form.setErrors({ serverError: true }),
});
Signal Forms
action: async (field) => {
  const result = await saveContact(field().value());
  if (result.ok) return;
  return { kind: 'serverError', message: 'Failed to submit form' };
},

Return an error (or a list of errors) from the action. Add a fieldTree property to attach an error to a specific field.

Source: Form submission (angular.dev)

isSaving flag

submitting()Submission
Reactive Forms
isSaving = false;

<button type="submit" [disabled]="isSaving">Send</button>
Signal Forms
<button type="submit" [disabled]="contactForm().submitting()">
  @if (contactForm().submitting()) { Sending... } @else { Send }
</button>

submitting() is true while the submission action is running.

Source: Form submission (angular.dev)

ControlValueAccessor

FormValueControlCustom controls
Reactive Forms
@Component({
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: RatingInput, multi: true }],
})
export class RatingInput implements ControlValueAccessor {
  writeValue(v: number) { /* ... */ }
  registerOnChange(fn: (v: number) => void) { /* ... */ }
  registerOnTouched(fn: () => void) { /* ... */ }
}
Signal Forms
@Component({ /* ... */ })
export class RatingInput implements FormValueControl<number> {
  value = model(0);
}

// Usage
<app-rating-input [formField]="reviewForm.rating" />

Implement FormValueControl and expose a value model signal. [formField] wires up value, validation and state; no provider or callbacks.

Source: Custom controls (angular.dev)

Checkbox ControlValueAccessor

FormCheckboxControlCustom controls
Reactive Forms
export class ToggleSwitch implements ControlValueAccessor {
  writeValue(checked: boolean) { /* ... */ }
  // registerOnChange, registerOnTouched ...
}
Signal Forms
export class ToggleSwitch implements FormCheckboxControl {
  checked = model<boolean>(false);
}

Checkbox-style controls expose a checked model signal instead of value.

Source: Custom controls (angular.dev)

Keep an existing FormControl

compatForm()Interop
Reactive Forms
const passwordControl = new FormControl('', {
  validators: [Validators.required, enterprisePasswordValidator()],
  nonNullable: true,
});
Signal Forms
import { compatForm } from '@angular/forms/signals/compat';

const user = signal({
  email: '',
  password: passwordControl, // existing FormControl
});

const f = compatForm(user);

Top-down migration: move the form to Signal Forms while keeping controls whose validators or RxJS logic are not ported yet.

Source: Migrating from Reactive Forms (angular.dev)

Replace one control inside a FormGroup

SignalFormControlInterop
Reactive Forms
form = new FormGroup({
  email: new FormControl('', Validators.required),
});
Signal Forms
import { SignalFormControl } from '@angular/forms/signals/compat';

emailControl = new SignalFormControl('', (p) => {
  required(p, { message: 'Email is required' });
});

form = new FormGroup({ email: this.emailControl });

<input [formField]="emailControl.fieldTree" />

Bottom-up migration: convert leaf controls first while the parent FormGroup stays. Values sync both ways.

Source: Migrating from Reactive Forms (angular.dev)

From Reactive Forms to Signal Forms

Signal Forms, stable since Angular 22, flip the Reactive Forms model around. Instead of building a tree of FormControl objects, you keep your data in a writable signal, pass it to form(), and declare validation and behavior as rules in a schema function. Values and state are signals, so there are no subscriptions, and field paths are fully typed. This reference pairs each Reactive Forms pattern with its Signal Forms equivalent, grouped into 9 topics.

Quick Mapping Table

Reactive FormsSignal FormsTopic
ReactiveFormsModuleFormFieldSetup
FormGroup / FormControlsignal() + form()Setup
FormBuilder / fb.group()form(model)Setup
formControlName / [formControl][formField]Setup
form.value / getRawValue()model() / field().value()Values
setValue() / patchValue()model.set() / value.set()Values
valueChanges.subscribe()computed() / effect()Values
form.get('address.city')form.address.cityValues
Validators.required / Validators.emailrequired() / email()Validation
Validators.min / max / minLength / maxLength / patternmin() / max() / minLength() / maxLength() / pattern()Validation
Custom ValidatorFnvalidate()Validation
FormGroup validator (password match)validate() + valueOf()Validation
AsyncValidatorFnvalidateHttp()Validation
addValidators() / removeValidators()rule option when / applyWhen()Validation
Zod / Valibot schema (manual adapter)validateStandardSchema()Validation
hasError() / errors in the templatefield().errors()State
valid / invalid / touched / dirty / pendingvalid() / invalid() / touched() / dirty() / pending()State
markAllAsTouched()markAsTouched()State
reset()reset() + model.set()State
.ng-valid / .ng-touched CSS classesprovideSignalFormsConfig()State
disable() / enable()disabled()Dynamic rules
Manual *ngIf / [readonly] logichidden() / readonly()Dynamic rules
valueChanges.pipe(debounceTime())debounce()Dynamic rules
Nested FormGroup / formGroupNameNested object in the modelNesting & arrays
FormArray (push / removeAt)Array in the model + applyEach()Nesting & arrays
Shared validator arrays / helper functionsschema() + apply()Nesting & arrays
(ngSubmit) + manual valid check[formRoot] + submission actionSubmission
Submitting from a methodsubmit()Submission
setErrors() after a failed requestReturn errors from the actionSubmission
isSaving flagsubmitting()Submission
ControlValueAccessorFormValueControlCustom controls
Checkbox ControlValueAccessorFormCheckboxControlCustom controls
Keep an existing FormControlcompatForm()Interop
Replace one control inside a FormGroupSignalFormControlInterop

A Safe Migration Order

  1. Upgrade to Angular 22 first; the 21 → 22 upgrade plan lists every step.
  2. Write new forms with Signal Forms and leave working Reactive Forms alone for now.
  3. Migrate leaf controls inside big forms with SignalFormControl, or wrap a form with compatForm() while keeping hard-to-port controls.
  4. Restore ng-* classes with provideSignalFormsConfig if your CSS depends on them.
  5. Replace ControlValueAccessor components with FormValueControl last; they work with both APIs.

Frequently Asked Questions

Are Signal Forms stable?
Yes. Signal Forms were experimental in Angular 21 and are stable from Angular 22. They live in the @angular/forms/signals package.
Do I have to migrate from Reactive Forms?
No. Reactive Forms and template-driven forms remain stable and supported. Signal Forms are the recommended option for new forms, and you can migrate existing ones gradually.
Can I migrate one form or one control at a time?
Yes. compatForm() lets a Signal Form keep existing FormControl instances (top-down), and SignalFormControl lets you swap a single control inside an existing FormGroup (bottom-up). Both come from @angular/forms/signals/compat.
What replaces valueChanges?
Form values are signals, so derived values use computed() and side effects use effect(). For debouncing, the debounce() rule delays model updates for a field.
Why do my .ng-invalid styles stop working?
Signal Forms do not add ng-* status classes by default. Add provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }) to your app providers to restore them.

Sources & Official Resources

Every Signal Forms snippet follows the official Angular Signal Forms guide. Last reviewed against Angular 22.

More in Angular tools.