Signal Forms: A New Era of Angular Forms

With the stable release of Signal Forms in Angular 22, the forms landscape has shifted significantly. If you're looking for a broader overview of the Angular 22 changes, our previous coverage is a good starting point. Here, we take a deep dive specifically into signal forms—arguably the most eagerly awaited feature in recent releases. We'll explore how they stack up against Reactive Forms, dissect the new validation and reactivity model, demonstrate building custom controls without the boilerplate of `ControlValueAccessor`, and guide you through a gradual migration path using compatForm.

Signal forms feel like a significant upgrade over their predecessors. The familiar concepts—like validators, dirty state, and validity—are still there, but their underlying implementation is now built entirely on signals. However, there are also notable departures: the triad of FormGroup, FormControl, and FormArray is gone. In their place, we get a type system that's finally as strict as it promises to be.

Getting Started with Signal Forms

Signal Forms – Complete Guide — figure 1

While signal forms are built on the signals we've come to know, they also introduce new vocabulary and functionalities that are unique to this system. A prime example is the concept of the form model.

Understanding the Form Model

The form model is a writable signal that serves as the initializer for your form. This is a critical distinction because the type of your form is directly derived from the signal's type. One of the most significant improvements is that forms are now strictly typed, with the type being inferred from the initial object you provide.

Moreover, the form maintains a real-time, bidirectional sync with this model signal. Any change to the model is instantly reflected in the form and vice-versa. In essence, the initialization signal is the single source of truth for the form's state.

export class LoginComponent {
 // Form model
 loginModel = signal({
   email: '',
   password: ''
 })

 // We init form with defined form model 
 loginForm = form(this.loginModel)
}

This approach marks a fundamental shift from the architecture of Reactive Forms. Previously, the form was the owner of its own state, existing independently of the source object. This required manual synchronization whenever you needed to update the form with external data.

// We map entity properties into form controls, since that point they are not synchronized

form = fb.group({
 email: [entity.email],
 password: [entity.password]
});

Creating Your First Form

You instantiate a new form using the form() function. This function-based approach is very much in keeping with Angular's ongoing functional trend. The function's first argument is the form model you've defined. It not only dictates the form's type but also drives the creation of the Form Tree—a hierarchical representation of your data. In this tree, each object becomes a node with its own children, and each primitive value is a leaf. This means navigating the form is now as intuitive as navigating your data structure.

import { form } from '@angular/forms/signals';

loginForm = form(this.loginModel);

// Navigation through dot - like a regular object
loginForm.email       // email field
loginForm.password    // password field

Typing: The End of Compromises

The typed Reactive Forms introduced in Angular 14 were a step forward, but they came with daily usability constraints. Signal forms, built from the ground up with TypeScript at the forefront, aim to resolve these lingering issues.

Issue 1: The Nullable Default

In Reactive Forms, a standard FormControl is typed as T | null by default.

const emailControl = new FormControl('');
// Type: FormControl<string | null>

emailControl.value; // string | null - always nullable!

You can bypass this with nonNullable, but it requires explicit specification for every single control.

const emailControl = new FormControl('', { nonNullable: true });
// Only now the type is FormControl<string>

With signal forms, the type is inferred straight from the model, so no extra annotation is required.

const model = signal({ email: '' });
const myForm = form(model);

myForm.email().value(); // string - no null!

Issue 2: The Unhelpful get() Method

One of the most frequent frustrations with typed Reactive Forms is the loss of type safety when retrieving controls.

const form = new FormGroup({
  user: new FormGroup({
    email: new FormControl(''),
    name: new FormControl('')
  })
});

// Even though form is typed...
const email = form.get('user.email');
// ...email is of type: AbstractControl<unknown, unknown> | null

// We have to cast manually
const emailTyped = form.get('user.email') as FormControl<string | null>;

The get() method accepts a string path, which TypeScript cannot validate. Signal forms provide fully typed navigation.

const model = signal({
  user: { email: '', name: '' }
});
const myForm = form(model);

// Full typing at every level
myForm.user.email().value(); // string

// Typo? Compilation error!
myForm.user.emial; // ❌ Property 'emial' does not exist

Issue 3: FormArray Loses Its Shape

Handling arrays in typed Reactive Forms can be particularly awkward.

const users = new FormArray([
  new FormGroup({
    name: new FormControl(''),
    email: new FormControl('')
  })
]);

// When accessing through at()...
users.at(0); // AbstractControl - we lose FormGroup structure information!
users.at(0).get('name'); // again AbstractControl | null

Signal forms maintain the full structure of the array without any trouble.

interface User {
  name: string;
  email: string;
}

const model = signal<{ users: User[] }>({
  users: [{ name: 'Jan', email: 'jan@example.com' }]
});

const myForm = form(model);

// Full typing preserved!
myForm.users[0].name().value();  // string
myForm.users[0].email().value(); // string

// Iteration is also typed
for (const [index, userField] of myForm.users) {
  userField.name().value();  // TypeScript knows it's a string
}

Issue 4: Handling Dynamic Forms

Adding controls at runtime in Reactive Forms often devolves into a typing minefield.

const form = new FormGroup({
  name: new FormControl('')
});

form.addControl('email', new FormControl(''));

// TypeScript still thinks form only has 'name'
form.controls.email; // ❌ Property 'email' does not exist

In signal forms, since the model is the source of truth, this problem disappears.

const model = signal<{ name: string; email?: string }>({ 
  name: '' 
});
const myForm = form(model);

// Adding a field = updating the model
model.update(m => ({ ...m, email: 'new@example.com' }));

// Typing automatically accounts for optional field
if (myForm.email) {
  myForm.email().value(); // string
}

The Mechanics: FieldTree

The core of the type system is the FieldTree<TModel> type, which recursively maps your model's structure to the form's structure. This means:

  • For an object, each property becomes a corresponding form field.
  • For an array, each element is accessible by index, with its type fully preserved.
  • For a primitive, it's a leaf—a terminal field with no children.

This design ensures TypeScript is always aware of the type for every field, eliminating the need for manual assertions, casting, or guesswork.

Summary of the Typing Revolution

Aspect Typed Reactive Forms Signal Forms
Nullable by default Yes (T | null) No – depends on model
Navigation (get() / dot) Loses types Full typing
Arrays at() returns AbstractControl Preserves structure
Dynamic fields Require type assertion Model as source of truth
Refactoring Partially safe Fully safe

In essence, typed Reactive Forms was an attempt to retrofit typing onto an existing API. Signal forms were designed from the start with TypeScript as a primary constraint. You'll see the difference from the very first line of code.

The New Validation Paradigm

Just like its predecessor, signal forms come with a set of pre-defined validators. However, the application method is completely different. Instead of attaching validators during control creation, you now call functions that point directly at the field and its corresponding validator.

import { form, required, minLength, email, pattern } from '@angular/forms/signals';

const loginForm = form(this.loginModel, (login) => {
  required(login.email);
  email(login.email);
  required(login.password);
  minLength(login.password, 8);
});
// Reactive Forms - validator at field
new FormControl('', [Validators.required, Validators.email])

// Signal Forms - validators in separate section
form(model, (f) => {
  required(f.email);
  email(f.email);
});

Whether this is an improvement in readability is a matter of personal taste. For smaller forms, the change is negligible. For larger, more complex forms, having validation centralized can be a distinct advantage.

Validators Provided by Default

A complete set of built-in validators is at your disposal:

required(path);                    // required field
min(path, minValue);               // minimum numeric value
max(path, maxValue);               // maximum numeric value  
minLength(path, length);           // minimum length
maxLength(path, length);           // maximum length
pattern(path, regex);              // regex pattern
email(path);                       // email format

Authoring Custom Validators

The process of creating a custom validator is more straightforward than ever.

import { form, validate, customError } from '@angular/forms/signals';

const registrationForm = form(this.model, (f) => {
  // Custom validator - function receives context with value
  validate(f.username, ({ value }) => {
    const username = value();
    if (username.includes(' ')) {
      return customError({ kind: 'no-spaces', message: 'Name cannot contain spaces' });
    }
    return undefined; // no error
  });
  
  // Validator with access to other fields
  validate(f.confirmPassword, ({ value, valueOf }) => {
    if (value() !== valueOf(f.password)) {
      return customError({ kind: 'password-mismatch', message: 'Passwords are not identical' });
    }
    return undefined;
  });
});

The validator's context (ctx) grants you access to:

  • value() – the current value of the field it's attached to.
  • valueOf(path) – the value of any other field within the form.
  • state – the full state (touched, dirty, etc.) of the current field.
  • stateOf(path) – the state of any other field.

Reactivity in Validators: Automatic Tracking

This is where signal forms truly shine. Validators operate within a reactive context, meaning Angular automatically tracks any signal that is read during their execution.

Let's illustrate this with a classic password confirmation validator.

validate(f.confirmPassword, ({ value, valueOf }) => {
  if (value() !== valueOf(f.password)) {
    return customError({ kind: 'password-mismatch' });
  }
  return undefined;
});

This validator automatically re-evaluates when:

  • The confirmPassword field changes because we call value() on it.
  • The password field changes because we use valueOf(f.password).

In short, the validator reacts to every signal it reads, not just the field to which it is assigned.

Why This Is a Game-Changer

Think back to the classic "passwords must match" experience in a Reactive Form:

  1. User types in the first field → second field's validator runs → error displayed.
  2. User types the same text into the confirmation field → validator runs → all good.
  3. User returns to the first field and changes the password → the confirmation field's validator is triggered automatically → error correctly shows again.

In a Reactive Form, step 3 would require a manual workaround like this:

// Reactive Forms - need to manually link
this.form.get('password').valueChanges.subscribe(() => {
  this.form.get('confirmPassword').updateValueAndValidity();
});

With signal forms, this happens for free. No subscriptions, no tedious calls to updateValueAndValidity().

A Note on Performance

Given that the validator reacts to all its dependencies, it's prudent to read only the signals that are strictly necessary.

// ⚠️ Reads entire form - will run on EVERY change
validate(f.someField, ({ stateOf }) => {
  const everything = stateOf(f).value(); // entire form!
  // ...
});

// ✅ Precise dependencies - will run only when one of two fields changes
validate(f.someField, ({ value, valueOf }) => {
  const mine = value();
  const related = valueOf(f.otherField);
  // ...
});

Handling Async Validation

To manage validations that require server communication, signal forms provide validateAsync and validateHttp.

import { validateHttp } from '@angular/forms/signals';

const form = form(this.model, (f) => {
  validateHttp(f.username, {
    request: ({ value }) => 
      value() ? `/api/check-username?name=${value()}` : undefined,
    onSuccess: (result) => 
      result.taken ? customError({ kind: 'taken', message: 'Name taken' }) : undefined,
    onError: () => 
      customError({ kind: 'server-error', message: 'Error checking availability' })
  });
});

It's worth noting that asynchronous validation only executes after synchronous validators have passed.

Controlling Field State Dynamically

Just like validators, these functions are reactive and allow for dynamic control of a field's state.

import { form, disabled, hidden, readonly } from '@angular/forms/signals';

const orderForm = form(this.model, (order) => {
  // Field disabled conditionally
  disabled(order.discountCode, ({ valueOf }) => 
    valueOf(order.orderType) === 'wholesale'
  );
  
  // Field hidden conditionally
  hidden(order.companyName, ({ valueOf }) => 
    valueOf(order.customerType) !== 'business'
  );
  
  // Read-only field
  readonly(order.totalPrice);
});

The key difference from Reactive Forms is their reactivity. A change to orderType will instantly enable or disable the discountCode field without any manual subscription or invoking enable()/disable().

What disabled, hidden, or readonly Imply

Fields in these states are excluded from influencing their parent's state. This means:

  • A hidden field with a validation error will not render an entire form invalid.
  • A disabled field marked as dirty will not affect the parent's dirty state.
  • A readonly field is skipped during validation.

Schematic Reusability with Schemas

The concept of schemas is entirely new to this system. It allows you to define a set of validation and state rules once, and then apply them across multiple forms.

import { schema, required, email, minLength } from '@angular/forms/signals';

// Define once
const addressSchema = schema<Address>((addr) => {
  required(addr.street);
  required(addr.city);
  required(addr.zipCode);
  pattern(addr.zipCode, /^\d{2}-\d{3}$/);
});

const contactSchema = schema<Contact>((contact) => {
  required(contact.email);
  email(contact.email);
  minLength(contact.phone, 9);
});

Implementation of Schemas

import { form, apply, applyEach } from '@angular/forms/signals';

// Apply to nested object
const customerForm = form(this.customerModel, (customer) => {
  required(customer.name);
  apply(customer.billingAddress, addressSchema);
  apply(customer.shippingAddress, addressSchema);
  apply(customer.contact, contactSchema);
});

// Apply to each array element
const orderForm = form(this.orderModel, (order) => {
  applyEach(order.addresses, addressSchema);
});

Schemas with Conditions

You can also apply schemas conditionally.

import { applyWhen, applyWhenValue } from '@angular/forms/signals';

const form = form(this.model, (f) => {
  // Schema applied when condition is met
  applyWhen(f.payment, 
    ({ valueOf }) => valueOf(f.paymentMethod) === 'card',
    cardPaymentSchema
  );
  
  // Schema applied based on field value (with type narrowing!)
  applyWhenValue(f.document,
    (doc): doc is Invoice => doc.type === 'invoice',
    invoiceSchema
  );
});

Schemas are a robust solution for managing complex application architectures. You define the rules for an "Address" model once, and you can be confident that every single form with an address will validate it identically.

The [field] Directive: A Unified Approach

In the old system, we had to distribute multiple different directives across our template and remember their specific roles.

<!-- Reactive Forms - different directives -->
<input [formControl]="emailControl">
<input formControlName="email">
<div formGroupName="address">...</div>
<div formArrayName="items">...</div>

Signal forms streamline this into a single [field] directive.

<!-- Signal Forms - always [field] -->
<input [field]="myForm.email">
<input [field]="myForm.address.street">
<input [field]="myForm.items[0].name">

Enhanced Typing Within Templates

This directive is strictly typed. If you try to bind a numeric field to a control expecting a string, Angular will surface a type error right in the template.

<!-- myForm.age is FieldTree<number> -->
<input type="text" [field]="myForm.age">
<!-- ❌ Type 'FieldTree<number>' is not assignable to type 'FieldTree<string>' -->

This catches type mismatches at development time, a capability never before possible with Angular forms.

Automatic State Management

Beyond just value binding, the [field] directive also manages synchronization between the field's state and the UI control.

// Control can declare these inputs - Field will automatically fill them
@Component({...})
export class MyInput {
  value = model<string>('');           // value - required
  disabled = input<boolean>(false);    // is disabled
  touched = input<boolean>(false);     // is touched
  errors = input<ValidationError[]>([]); // validation errors
  required = input<boolean>(false);    // is required
  // ... and more
}

<my-input [field]="myForm.email"></my-input>
<!-- All states synchronized automatically -->

The FormValueControl Contract

To create a custom control for the [field] directive, you now only need to implement a concise interface rather than a cumbersome ControlValueAccessor.

import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'my-custom-input',
  template: `...`
})
export class MyCustomInput implements FormValueControl<string> {
  // Only required field
  readonly value = model<string>('');
  
  // Optional - Field will automatically bind if they exist
  readonly disabled = input<boolean>(false);
  readonly errors = input<ValidationError[]>([]);
  readonly touched = input<boolean>(false);
}

Migration Strategy with compatForm

For applications with heavy investment in Reactive Forms, a complete rewrite is often ill-advised. Recognizing this, the Angular team has provided compatForm(), a compatibility layer that allows both form systems to coexist within a single form tree.

import { compatForm } from '@angular/forms/signals';
import { FormControl, Validators } from '@angular/forms';

// Existing FormControl with validators
const ageControl = new FormControl(5, Validators.min(3));

// Model mixing signal forms with Reactive Forms
const model = signal({
  name: 'Jan',           // regular signal forms field
  age: ageControl        // existing FormControl
});

const myForm = compatForm(model);

The Core Principals

The function automatically extracts the value from your FormControl instances.

myForm.age().value();    // 5 (number, not FormControl!)
myForm.name().value();   // 'Jan'

// If you need access to the original FormControl:
myForm.age().control();  // FormControl<number>

Keeping Both Worlds in Sync

The serialization is bi-directional, meaning state changes will synchronize whether they originate from the old or the new system.

// Change through FormControl
ageControl.setValue(10);
myForm.age().value();        // 10

// Change through signal forms
myForm.age().value.set(15);
ageControl.value;            // 15

// Touched/dirty also propagates
ageControl.markAsTouched();
myForm.age().touched();      // true
myForm().touched();          // true (propagation to parent)

Respecting Existing Validators

Validators attached to your existing Reactive Forms controls are still honored by the new system.

const control = new FormControl(1, Validators.min(5));
const model = signal({ age: control });
const myForm = compatForm(model);

myForm.age().valid();   // false
myForm().valid();       // false (propagation)

control.setValue(10);
myForm.age().valid();   // true

Signal Forms – Complete Guide — figure 2

Limitation: No Mixing Rules

You cannot, however, apply signal form rules (such as required() or validate()) directly to an existing FormControl. The strict TypeScript typing will prevent it.

compatForm(model, (f) => {
  required(f.name);     // ✅ OK - regular field
  required(f.age);      // ❌ Compilation error - age is FormControl
  
  // But you can read FormControl values in validators of other fields:
  validate(f.name, ({ valueOf }) => {
    return valueOf(f.age) < 18 
      ? customError({ kind: 'too-young' }) 
      : undefined;
  });
});

This constraint is logical—it keeps the existing validation logic within the FormControl it belongs to, preventing confusion from two different validation systems on the same field.

Managing Submission and Reset

Streamlined Form Submission

The submit() function manages the typical submission cycle:

import { submit } from '@angular/forms/signals';

async function onSubmit() {
  await submit(myForm, async (form) => {
    // 1. At this point all fields are already marked as touched
    // 2. If form is invalid - this function will NOT be called
    // 3. form().submitting() === true during execution
    
    const response = await api.save(form().value());
    
    // We can return server errors
    if (response.error) {
      return [{
        field: myForm.email,
        error: customError({ kind: 'server', message: response.error })
      }];
    }
    
    return undefined; // success
  });
}

So, what does submit() handle for you?

  1. It marks all fields as touched to trigger error display.
  2. It validates the form. If invalid, it aborts the action.
  3. It sets a submitting state to true.
  4. It invokes your provided action function.
  5. If server errors are returned, it maps them to the corresponding fields.
  6. Finally, it sets submitting back to false.

Reacting to the Submitting Flag

You can use the submitting state to disable your UI and prevent duplicate attempts.

<button [disabled]="myForm().submitting()">
  {{ myForm().submitting() ? 'Sending...' : 'Submit' }}
</button>

This state is also propagated down the tree—if any parent is submitting, its children will reflect this.

myForm().submitting();           // true
myForm.email().submitting();     // true

Resetting the Form

The reset() method is used to clear the interaction state of the fields (e.g., touched, dirty).

myForm.email().reset();  // resets single field
myForm().reset();        // resets entire form and all children

You have the option to pass a new value to reset the form to as well.

myForm().reset({ email: '', password: '' });

Remember: calling reset() without an argument does not alter the form's value; it solely resets its UI state.

Debouncing Input

For fields, that shouldn't be the source of action on every keystroke, like a search box, you can leverage debounce().

import { form, debounce } from '@angular/forms/signals';

const searchForm = form(this.model, (f) => {
  // Update model only 300ms after last change
  debounce(f.query, 300);
});

You also have the ability to provide a custom debounce function if needed.

debounce(f.query, (ctx, abortSignal) => {
  return new Promise(resolve => {
    const timeout = setTimeout(resolve, 500);
    abortSignal.addEventListener('abort', () => clearTimeout(timeout));
  });
});

Debouncing is inherited by all child fields. However, a child can override this behavior by setting its own debounce function.

Custom Controls – Saying Goodbye to ControlValueAccessor

In Reactive Forms, building a custom control meant implementing the ControlValueAccessor interface. That involved four methods, a provider with forwardRef that always looked a bit magical, and manually firing onChange and onTouched at the right moments. If you’ve written forms in Angular, this is familiar boilerplate:

// Reactive Forms - ControlValueAccessor ?
@Component({
  selector: 'my-input',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => MyInputComponent),
      multi: true
    }
  ]
})
export class MyInputComponent implements ControlValueAccessor {
  private onChange: (value: string) => void = () => {};
  private onTouched: () => void = () => {};
  
  writeValue(value: string): void { /* ... */ }
  registerOnChange(fn: (value: string) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(isDisabled: boolean): void { /* ... */ }
}

Signal Forms cut that down to a single line.

FormValueControl – A Minimal Contract

To build a control that works with the [field] directive, all you need is to implement the FormValueControl<T> interface:

import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'my-input',
  template: `
    <input 
      [value]="value()" 
      (input)="value.set($event.target.value)"
    />
  `
})
export class MyInputComponent implements FormValueControl<string> {
  readonly value = model('');
}

That’s the whole setup. Create one model() signal and your control is done:

<my-input [field]="myForm.email"></my-input>

The [field] directive keeps the form and the control in sync automatically. When the form changes, the value() updates; when the control changes, the form model catches up.

Optional Inputs – State Binding on Autopilot

The FormValueControl interface comes with several optional inputs. If you declare them in your control, the [field] directive populates them for you:

@Component({
  selector: 'my-input',
  template: `
    <div class="input-wrapper" [class.has-error]="invalid()">
      <input 
        [value]="value()" 
        [disabled]="disabled()"
        [attr.name]="name()"
        (input)="value.set($event.target.value)"
        (blur)="touched.set(true)"
      />
      @if (invalid() && touched()) {
        <div class="errors">
          @for (error of errors(); track error.kind) {
            <span>{{ error.message }}</span>
          }
        </div>
      }
    </div>
  `
})
export class MyInputComponent implements FormValueControl<string> {
  // Required
  readonly value = model('');
  
  // Optional - Field will automatically bind if they exist
  readonly disabled = input(false);
  readonly touched = model(false);  // model() allows two-way binding
  readonly errors = input<ValidationError[]>([]);
  readonly invalid = input(false);
  readonly name = input('');
  readonly required = input(false);
  readonly readonly = input(false);
}

Here’s the full set of optional inputs:

  • disabled – signals whether the field is disabled
  • readonly – signals whether the field is read-only
  • touched – signals user interaction (can be a model() for two-way binding)
  • dirty – signals whether the value has been changed
  • invalid – signals whether validation has failed
  • pending – signals whether async validation is running
  • errors – holds the list of validation errors
  • name – the field name within the form
  • required – signals whether the field is required
  • min, max, minLength, maxLength, pattern – values pulled from validators

Only the inputs you actually declare are filled in; any others are simply skipped.

FormCheckboxControl – Made for Checkboxes

For checkbox-style controls, there’s a dedicated contract called FormCheckboxControl:

import { Component, model } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';

@Component({
  selector: 'my-checkbox',
  template: `
    <label>
      <input 
        type="checkbox"
        [checked]="checked()" 
        (change)="checked.set($event.target.checked)"
      />
      <ng-content></ng-content>
    </label>
  `
})
export class MyCheckboxComponent implements FormCheckboxControl {
  readonly checked = model(false);
}

And here’s how you’d use it:

<my-checkbox [field]="myForm.agreeToTerms">
  I accept the terms
</my-checkbox>

Directives as Controls

A control doesn’t need to be a component – a directive applied to a native element works just as well:

@Directive({
  selector: 'input[myCustomInput]',
  host: {
    '[value]': 'value()',
    '(input)': 'value.set($event.target.value)',
    '(blur)': 'onBlur()'
  }
})
export class MyCustomInputDirective implements FormValueControl<string> {
  readonly value = model('');
  readonly touched = model(false);
  
  onBlur() {
    this.touched.set(true);
  }
}

<input myCustomInput [field]="myForm.email" />

The [field] directive will spot the FormValueControl implementation and wire it into the form on its own.

Signal Forms strip away the extra ceremony. No need to implement a four-method interface or fiddle with providers; declare the signal and the control is good to go.

Points to Keep in Mind

Experimental Status

Signal forms are labeled @experimental in 21.0.0. What does that mean in practice?

  • The API may shift in upcoming releases (even if the core concept stays intact)
  • There could be edge cases and bugs you stumble into
  • Documentation is still being fleshed out

Is that a reason to avoid them? I don’t think so – particularly for greenfield projects they’re a solid bet. But for critical production systems, think carefully about whether you’re okay with the prospect of API migrations down the line.

How to Import

Signal forms come from their own entry point:

import { form, required, validate, ... } from '@angular/forms/signals';

Steer clear of mixing these with imports from @angular/forms unless you’re specifically working with compatForm.

Wrapping It Up

Signal forms aren’t just an incremental update to Reactive Forms – they’re a complete rewrite of how Angular handles forms. Here’s what’s different:

  • The model is the source of truth – the form and your data stay in lockstep
  • Real typing support – TypeScript has full visibility, with no workarounds
  • Reactivity without extra wiring – validators listen to dependencies on their own
  • A single API – the [field] directive takes over from the directive overload
  • Schemas – write validation rules once and reuse them
  • Simpler controlsFormValueControl replaces the ControlValueAccessor ordeal

Thinking of migrating an existing app? If time and budget allow, it’s worth doing. If not, compatForm lets you adopt signal forms incrementally, one form at a time.

And for new work? There’s really no decision to make. Signal forms are where Angular forms are headed.