25 Aug 2026
27 min

Signal Forms – Complete Guide v.21-22

Signal Forms are stable as of Angular 22. If you haven’t had a chance to familiarize yourself with all the changes that landed in this release, I encourage you to check out our overview article.

Here we will focus only and exclusively on signal forms, which by the way were probably the most anticipated feature of recent months (years?). In this article you will learn how signal forms differ from Reactive Forms, what the new validation and reactivity model looks like, how to create custom controls without ControlValueAccessor boilerplate, and how to migrate existing forms gradually, control by control, with SignalFormControl and compatForm.

Signal forms compared to their predecessors are a breath of fresh air. Familiar concepts like validators, dirty state, valid or invalid remain, but their implementation has been completely rewritten using signals. However, not everything is identical to predecessors. We no longer have concepts like form group, form control or form array. On the other hand, typed forms are now actually typed.

Introduction to Signal Forms

Signal forms themselves are already a novelty, working based on signals that we already know quite well. Nevertheless, they introduce new nomenclature and features to the world of forms that we have no chance of knowing based on previous years with Reactive and Template Driven Forms. One of them is form model.

Form Model

Form model is a writable signal with which we initialize our form. This is crucial because form model directly corresponds to the type of our form. Forms are now very well typed and directly infer the type from the initializing object.

Another very important point is that any modifications and updates to our form model will be directly propagated and reflected by the form. Currently this initialization signal is the owner of the state that the form represents, and they remain in full synchronization.

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

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

This is a fundamental change compared to reactive forms. In the previous approach, the form managed its own state independently. We mapped object fields to form controls, but the form state existed independently from the source object. Any synchronization of the form with an external model required manual value updates.

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

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

Initializing the Form

Creating a new form is done through the form() function. This is already characteristic for Angular that more and more functionality is implemented in a functional way. The first argument of the function is our previously mentioned form model, which will give type to our form and based on it the Form Tree will be initialized: a hierarchical structure of fields where each object in the model becomes a node with its own children, and each primitive value becomes a terminal field (leaf). Thanks to this, navigation through the form naturally corresponds to navigation through data.

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 – End of Compromises

Typed Reactive Forms introduced in Angular 14 were a step in the right direction. However, in practice their typing has limitations that can frustrate on a daily basis. Signal forms, designed from the ground up with TypeScript in mind, solve these problems.

Problem 1: Nullable Everywhere

In Reactive Forms each FormControl by default has type T | null:

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

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

We can use nonNullable, but it requires explicit declaration for each control:

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

Signal forms take the type directly from the model:

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

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

Problem 2: get() Method Loses Types

This is one of the most irritating aspects of typed Reactive Forms:

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 takes a string, so TypeScript is not able to verify if the path is correct.

Signal forms give full navigation typing:

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

Problem 3: FormArray Loses Structure

FormArray in typed forms can be problematic:

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 preserve full structure:

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 in templates is typed too
// @for (userField of myForm.users; track userField) {
//   <input [formField]="userField.name" />
// }

Problem 4: Dynamic Forms

Adding controls at runtime is a typing nightmare:

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 the model is the source of truth. One caveat before the example: Signal Forms treats undefined as the absence of a field rather than an empty value, so for ordinary fields you initialise with ”, 0 or []. The optional property below is about typing a field that genuinely may not exist, not a pattern for empty inputs.

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
}

How Does It Work Under the Hood?

The heart of the type system is FieldTree<TModel>, a type that recursively maps model structure to form structure:

  • For objects, each property becomes a form field
  • For arrays, elements are accessible by index with preserved type
  • For primitives, a terminal field without children

Thanks to this, TypeScript always knows what type each field has. No manual assertions, no casting, no guessing.

Summary

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

Typed Reactive Forms were a compromise, because typing was added to an existing API. Signal forms were designed from scratch with TypeScript as a priority. The difference is noticeable from the first line of code.

Validation

Similar to Reactive Forms, we have access to predefined validators. However, the way of applying them is completely different. Instead of passing validators when creating a control, we call functions pointing to the field and 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);
});

All validators are passed in one place, as the second parameter of the form() function. This centralizes validation logic, which has its pros and cons. On one hand, we have a full picture of rules in one place. On the other hand, the validator is not applied directly next to the field definition, as it used to be in Reactive Forms:

// 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);
});

Everyone will have to subjectively assess how this affects form readability. For small forms the difference is cosmetic. For large ones, centralization can be an advantage.

Predefined Validators

Signal forms provide a set of built-in validators:

required(path);                    // required field
min(path, minValue);               // minimum numeric value
max(path, maxValue);               // maximum numeric value
minDate(path, date);               // earliest allowed date
maxDate(path, date);               // latest allowed date
minLength(path, length);           // minimum length
maxLength(path, length);           // maximum length
pattern(path, regex);              // regex pattern
email(path);                       // email format

Every built-in validator accepts an options object as its last argument. The one you will reach for constantly is message:

required(path.email, { message: 'Email is required' });
minLength(path.password, 8, { message: 'At least 8 characters' });

This removes an entire layer of ceremony. In Reactive Forms the validator produced an error key and something in the template had to translate that key into a sentence, usually a lookup object nobody wanted to own. Here the message travels with the rule.

Two edge cases in required() are worth knowing before they surprise you. An empty array counts as present, so required() will not enforce “pick at least one” on an array field; use minLength() for that. And false counts as missing, which matches how <input type=”checkbox” required> behaves in the browser but catches people out on a boolean field that legitimately holds false.

Conditional Validators – the when Option

The same options object accepts a when predicate, which is the shortest way to make a single rule conditional:

required(path.delayReason, {
  when: (ctx) => ctx.valueOf(path.delayed),
});

The predicate receives the same context as a validator, so valueOf and stateOf give you the rest of the form. Because it runs in a reactive context, the rule activates and deactivates on its own as delayed changes. No setValidators(), no updateValueAndValidity().

Use when for one rule on one field. Reach for applyWhen (covered later) when an entire group of rules turns on and off together.

Custom Validators

Creating custom validators is simpler than ever:

import { form, validate } 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 { 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 { kind: 'password-mismatch', message: 'Passwords are not identical' };
    }
    return undefined;
  });
});

Validator context (ctx) gives access to:

  • value() – current field value
  • valueOf(path) – value of any other field
  • state – full field state (touched, dirty, etc.)
  • stateOf(path) – state of any other field

Note that there is no customError() helper. A validator returns a plain object with a kind and an optional message, or null / undefined when the value is valid.

For errors that mirror a built-in rule, Signal Forms exports a factory for each one: requiredError(), minError(), maxError(), minLengthError(), maxLengthError(), patternError(), emailError(), minDateError(), maxDateError() and standardSchemaError(). Alongside them are the metadata keys those rules use: REQUIRED, MIN, MAX, MIN_NUMBER, MAX_NUMBER, MIN_LENGTH, MAX_LENGTH, PATTERN, MIN_DATE, MAX_DATE.

This matters more than it looks. A shared error-display component that switches on error.kind will handle your custom rule without a special case, and a custom control declaring a minLength input will still receive the constraint value.

Replacing a Built-in Message Entirely

The options object accepts message or error, but not both – they are mutually exclusive in the type. Use error when you want to replace the whole error object rather than just its text:

required(path.email, {
  error: { kind: 'server', message: 'This address is already registered' },
});

validateTree – Errors That Land on Another Field

validate() attaches an error to the field it validates. Sometimes the rule belongs to a subtree but the error belongs somewhere specific. validateTree() covers that case, and the error object gains a fieldTree property naming its target:

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

const userForm = form(this.model, (path) => {
  validateTree(path, (ctx) => {
    if (ctx.valueOf(path.firstName).length < 5) {
      return {
        kind: 'minLength5',
        message: 'First name must be at least 5 characters',
        fieldTree: ctx.fieldTree.lastName,
      };
    }
    return null;
  });
});

This is also how cross-field rules stop being awkward. A rule that reads three fields can report on the one the user should actually fix.

Validator Reactivity – Automatic Dependency Tracking

Here lies one of the biggest advantages of signal forms. Validators work inside a reactive context, which means Angular automatically tracks all read signals.

Let’s look at a password comparison validator:

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

This validator will run when:

  • confirmPassword changes (because we call value())
  • password changes (because we call valueOf(f.password))

So the validator reacts to changes of every read signal, not just the field it’s assigned to.

Why Is This Revolutionary?

Think about the classic “passwords must match” scenario:

  1. User enters password in password, the confirmPassword validator runs, error (confirm is empty)
  2. User enters the same in confirmPassword, validator runs, OK
  3. User goes back and changes password, the confirmPassword validator automatically runs, error (they no longer match)

In Reactive Forms point 3 required manual work:

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

In signal forms this happens automatically. Zero subscriptions, zero manual updateValueAndValidity() calls.

Performance Consideration

Since the validator reacts to all read signals, it’s worth reading only what’s really needed:

// ⚠️ 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);
  // ...
});

Asynchronous Validation

For validation requiring server requests we have validateAsync and validateHttp:

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

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

Asynchronous validation runs only when synchronous validation passes successfully.

Both validateAsync() and validateHttp() accept a debounce option. Note that this debounces the validator, not the field. The model stays current for everything else reading it, and only the network call is throttled. That is almost always what you want, and it is a better default than debouncing the field itself.

Conditional Functions

Analogously to validators, we have functions allowing dynamic control of field state:

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

const orderForm = form(this.model, (order) => {
  // Field disabled conditionally
  disabled(order.discountCode, {
    when: ({ valueOf }) => valueOf(order.orderType) === 'wholesale',
  });

  // Field hidden conditionally
  hidden(order.companyName, {
    when: ({ valueOf }) => valueOf(order.customerType) !== 'business',
  });

  // Read-only field, unconditionally
  readonly(order.totalPrice);
});

The condition goes in a when property. Passing the predicate directly as a second argument still compiles but is deprecated, so if you are reading older material that shows disabled(path, predicate), that is the legacy form.

when earns the extra braces in two ways. It can return a string instead of true, and that string becomes the reason the field is disabled:

disabled(order.discountCode, {
  when: ({ valueOf }) =>
    valueOf(order.orderType) === 'wholesale'
      ? 'Discount codes do not apply to wholesale orders'
      : false,
});

It also accepts a static string directly, for a field that is always disabled with an explanation. And calling disabled() several times on the same field accumulates every reason returned, rather than the last one winning.

A custom control declaring a disabledReasons input receives that text and can show it next to the greyed-out field. No parallel lookup table mapping field names to explanations, because the reason lives next to the rule that produced it.

Key difference from Reactive Forms: these functions are reactive. Changing orderType will automatically enable or disable the discountCode field, without manual subscribing and calling enable() / disable().

hidden() Does Not Hide Anything

This trips up nearly everyone on first contact. There is no native DOM property for hidden state, so the directive does not apply a hidden attribute and nothing disappears from the page. The rule marks the field as hidden in the form state, which excludes it from validation and from the parent’s state. Removing it from the view is your job:

@if (!orderForm.companyName().hidden()) {
  <input [formField]="orderForm.companyName" />
}

Use @if or CSS. If you skip this step, the field keeps rendering while silently no longer participating in validation, which is the worst of both worlds.

What Does disabled/hidden/readonly Mean?

Fields in these states are skipped when determining parent state:

  • Hidden field with an error doesn’t make the form invalid
  • Disabled field marked as dirty doesn’t affect parent’s dirty
  • Readonly field doesn’t contribute to the validation, touched or dirty state of its parent

All three states share the same treatment: while active, they skip validation and prevent the user from editing the field. That is the point worth internalising. A hidden field holding stale invalid data will not block your submit, which is usually what you want, and occasionally exactly what bites you.

CSS Classes – provideSignalFormsConfig

Reactive Forms applied ng-valid, ng-invalid, ng-dirty and friends automatically, whether you styled them or not. Signal Forms does not. You opt in and declare the mapping yourself:

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideSignalFormsConfig({
      classes: {
        'ng-valid':    ({ state }) => state().valid(),
        'ng-invalid':  ({ state }) => state().invalid(),
        'ng-touched':  ({ state }) => state().touched(),
        'ng-dirty':    ({ state }) => state().dirty(),
        'ng-pending':  ({ state }) => state().pending(),
      },
    }),
  ],
};

If you are migrating and your stylesheets already depend on the classic names, there is a prebuilt config that reproduces them exactly:

import { NG_STATUS_CLASSES } from '@angular/forms/signals/compat';

provideSignalFormsConfig({ classes: NG_STATUS_CLASSES });

Being explicit is an improvement, not a regression. You decide which states deserve a class, and a mapping is just a predicate over field state, so nothing stops you from declaring is-warning for a field that is valid but dirty.

Reusability – Schema

Schemas are a complete novelty. Schema allows defining a set of rules once and applying them in multiple places:

import { schema, required, email, minLength, pattern } 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);
});

Applying 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);
});

Conditional Schemas

We can 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
  );
});

Note that applyWhen and applyWhenValue still take their predicate as a positional second argument. The when property covered earlier belongs to validators and to disabled / hidden / readonly, not here.

Schemas are a powerful tool for organizing application architecture. You define rules for Address once, and you’re sure that every form with an address validates it identically.

Validation Libraries – validateStandardSchema

Standard Schema is a community interface implemented by Zod, Valibot and others. Signal Forms understands it directly, so a schema you already use for API validation can drive the form with no adapter in between:

import { validateStandardSchema } from '@angular/forms/signals';
import { z } from 'zod';

const FlightSchema = z.object({
  from: z.string().min(3).max(20),
  to: z.string().min(3).max(20),
  date: z.string(),
});

const flightForm = form(this.model, (path) => {
  validateStandardSchema(path, FlightSchema);
});

For a lot of teams this is the real answer to “how do we share validation between frontend and backend”. One Zod schema, used in both places, with the form as just another consumer.

Dynamic Schemas

Instead of a fixed schema object you can pass a lambda. It is converted internally into a computed, so reading a signal inside it makes the ruleset itself reactive:

validateStandardSchema(path, () =>
  strict() ? StrictFlightSchema : FlightSchema
);

Flip the strict signal and the entire validation strategy swaps. Useful for draft versus final submission, or rules that depend on the user’s role.

FormField Directive – One Way for Everything

In Reactive Forms we had to remember about different directives:

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

Signal forms simplify this to one [formField] directive:

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

Connecting the Form Element – FormRoot

FormField binds individual controls. Wiring the form as a whole to a native <form> element is the job of a second directive, FormRoot:

<form [formRoot]="myForm">
  <input [formField]="myForm.email" />
  <input [formField]="myForm.password" type="password" />
  <button>Log in</button>
</form>

FormRoot does three things. It sets novalidate, so the browser’s native validation tooltips stay out of your way. It intercepts the default submit behaviour, so the page does not reload and you never write $event.preventDefault() again. And it connects the submit event to the submission logic declared on the form, which means a plain <button> with no type and no click handler is all you need.

Both directives are standalone, so add them to the component’s imports:

import { FormField, FormRoot } from '@angular/forms/signals';

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

Typing in Template

The FormField directive is strictly typed. When you try to bind a number type field to an input expecting string:

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

This is something unseen before in Angular forms: type errors detected in template.

Automatic State Binding

The FormField directive automatically synchronizes state between field and UI control:

// Control can declare these inputs - FormField 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<readonly ValidationError.WithOptionalFieldTree[]>([]);
  required = input<boolean>(false);      // is required
  // ... and more
}
<my-input [formField]="myForm.email"></my-input>
<!-- All states synchronized automatically -->

FormValueControl Contract

To create a custom control compatible with [formField], you just need to implement a simple contract:

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 - FormField will automatically bind if they exist
  readonly disabled = input<boolean>(false);
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
  readonly touched = input<boolean>(false);
}

Migration – Two Directions

Angular gives you two bridges, and they point in opposite directions. Which one you need depends on where you are starting: a signal form that has to accommodate a few legacy controls, or a legacy form that you want to improve one control at a time. The second case is far more common.

Top-down: compatForm

If you have an existing application with Reactive Forms, you probably won’t rewrite everything at once (and rightly so). Fortunately, Angular anticipated this scenario and provides compatForm(), a function allowing to mix both worlds.

import { compatForm } from '@angular/forms/signals/compat';
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);

How Does It Work?

compatForm automatically unwraps values from FormControl:

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

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

Note the boundary of that unwrapping. It happens per field, not for the form as a whole:

myForm.age().value();   // 5
myForm().value();       // { name: 'Jan', age: FormControl }

If you need the complete form value, assemble it yourself:

const formValue = computed(() => ({
  name: myForm.name().value(),
  age: myForm.age().value(),
}));

compatForm also accepts a whole FormGroup as a field, not just a single control. The group becomes a branch of the form tree, and you reach its children through .control():

const model = signal({
  customerName: '',
  shippingAddress: addressGroup,  // existing FormGroup
});

Bidirectional Synchronization

State is synchronized both ways:

// 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)

Validators Are Respected

Validators defined on FormControl work normally:

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

Limitation: No Rules on FormControl Fields

You cannot apply signal forms rules (like required(), validate()) directly to fields that are FormControl. TypeScript will block 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
      ? { kind: 'too-young' }
      : undefined;
  });
});

This makes sense. FormControl validation should stay with that FormControl, and mixing two validation systems on one field is asking for trouble.

Bottom-up: SignalFormControl

The limitation above is exactly why the second bridge exists. If you cannot put signal forms rules on a FormControl, you put a signal-based control into the FormGroup instead.

A SignalFormControl behaves like an ordinary AbstractControl, so it drops straight into an existing FormGroup or FormArray and propagates value, status and validity through the hierarchy as before. What changes is how its rules are written:

import { SignalFormControl } from '@angular/forms/signals/compat';
import { required, email, validateHttp } from '@angular/forms/signals';

@Component({
  selector: 'app-user-form',
  imports: [ReactiveFormsModule, FormField],
  template: `
    <form [formGroup]="userForm">
      <input formControlName="firstName" />
      <input [formField]="emailControl.fieldTree" />
    </form>
  `,
})
export class UserFormComponent {
  private readonly fb = inject(FormBuilder);

  readonly emailControl = new SignalFormControl<string>('', (path) => {
    required(path, { message: 'Email is required' });
    email(path, { message: 'Provide a valid email address' });

    validateHttp(path, {
      debounce: 300,
      request: ({ value }) => `/api/check-email?email=${value()}`,
      onSuccess: (res: { taken: boolean }) =>
        res.taken
          ? { kind: 'taken', message: 'Already registered' }
          : null,
      onError: () =>
        { kind: 'network', message: 'Could not verify' },
    });
  });

  readonly userForm = this.fb.nonNullable.group({
    firstName: ['', Validators.required],
    email: this.emailControl,
  });
}

The parent FormGroup, the formControlName binding on firstName, the submit handler: none of it changes. Note that the signal-based control binds with [formField]=”emailControl.fieldTree”, not with formControlName. That is the documented way, and using formControlName or [formControl] for a SignalFormControl is explicitly discouraged.

One leaf control now has declarative, debounced async validation that would otherwise have been a switchMap pipeline with a subscription to clean up.

This is what makes gradual adoption realistic. You do not need a migration plan that goes form by form. You need a rule of thumb: when a control’s validation starts to hurt, convert that control.

The Migration Cost: Imperative Calls Stop Working

Before any of that, the constructor itself surprises people. Everywhere else in Signal Forms you pass a signal: form(mySignal). SignalFormControl takes a raw value:

// Raw value, not a signal
const emailControl = new SignalFormControl('', (p) => { required(p); });

It creates the signal internally, and it has to, in order to intercept writes and deliver the synchronous updates that Reactive Forms expects. If you need the signal, it is exposed as .sourceValue:

const current = emailControl.sourceValue();

Here is the part that decides how expensive a conversion actually is, and the compiler will not warn you about any of it.

SignalFormControl extends AbstractControl, so every method you are used to still exists on the type. The surrounding code that manipulates the control keeps compiling. It throws at runtime instead.

// Compiles. Throws when it runs.
this.emailControl.disable();
this.emailControl.setValidators([Validators.required]);
this.emailControl.setErrors({ taken: true });

The reasoning is consistent: in Signal Forms, availability and validity are derived from rules rather than assigned from outside, so an imperative setter would create a second source of truth. Each call has a declarative replacement:

Legacy call Replacement
enable() / disable() disabled(p, { when: … }) rule
addValidators() / removeValidators() / setValidators() applyWhen(p, condition, schema)
setErrors() / markAsPending() a validation rule in the schema

So disable() driven by a loading flag becomes this:

readonly emailControl = new SignalFormControl<string>('', (p) => {
  disabled(p, { when: () => this.isLoading() });
});

The practical consequence: before converting a control, grep for every call site that touches it imperatively. Writing the new control takes minutes. Finding the four places that call setValidators() on it is the actual work.

Custom Controls Cross Both Worlds

There is one more piece of interop, and it is the one that changes the migration calculus most.

A custom control implementing FormValueControl works in Reactive Forms and template-driven forms as-is. The same component binds with [formField] in a signal form and with formControlName in a FormGroup, with no adapter and no second implementation:

<!-- Signal Forms -->
<my-input [formField]="myForm.email" />

<!-- Reactive Forms, same component -->
<form [formGroup]="reactiveGroup">
  <my-input formControlName="email" />
</form>

So migrating a component library away from ControlValueAccessor does not break its existing usages. “We cannot move to Signal Forms until our component library is rewritten” stopped being true, and the rewrite pays off in both systems at once.

One hard rule: do not implement both ControlValueAccessor and FormValueControl / FormCheckboxControl on the same component. Pick one.

Submit and Reset

Submitting the Form

Submission is configured on the form itself, as a third argument to form(). Together with FormRoot in the template, this means a working submit flow without a single event handler:

import { form, required, email, FormField, FormRoot } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="loginForm">
      <input [formField]="loginForm.email" />
      <input [formField]="loginForm.password" type="password" />
      <button [disabled]="loginForm().submitting()">
        {{ loginForm().submitting() ? 'Sending...' : 'Log in' }}
      </button>
    </form>
  `,
})
export class LoginComponent {
  private readonly api = inject(AuthApi);
  private readonly loginModel = signal({ email: '', password: '' });

  readonly loginForm = form(
    this.loginModel,
    (path) => {
      required(path.email, { message: 'Email is required' });
      email(path.email);
      required(path.password);
    },
    {
      submission: {
        action: async (field) => {
          const response = await this.api.login(field().value());

          // Server-side errors are returned, not thrown
          if (response.error) {
            return { kind: 'server', message: response.error, fieldTree: field.email };
          }
          return undefined;
        },
        ignoreValidators: 'none',
        onInvalid: (field) => {
          field().errorSummary()[0]?.fieldTree().focusBoundControl();
        },
      },
    },
  );
}

What happens on submit:

  1. Marks interactive fields as touched, so their errors become visible. Hidden, disabled and readonly fields are skipped.
  2. Checks validation. If a rule has failed, the action does not run and onInvalid is called instead.
  3. Sets submitting to true
  4. Calls the action with the form’s current value
  5. Applies any errors the action returned to the corresponding fields
  6. Sets submitting to false

ignoreValidators – the Detail That Bites

Step 2 above has a default that surprises people. submit() does not wait for pending async validators. If nothing has failed yet, the action runs even while a username-availability check is still in flight. That is the ‘pending’ default, and on a registration form it is almost certainly not what you want.

  • ‘pending’ (default) – submits even if async validators are still running
  • ‘none’ – blocks until every validator, sync and async, has settled clean
  • ‘all’ – submits regardless of validation state, the draft-saving case

If your form has any async validation with consequences, set ‘none’ explicitly. Relying on the default here is how duplicate accounts get created.

Worth flagging: the async operations guide describes submission as waiting for validation to complete, which reads as the opposite of the table above. The form submission guide is the one that spells out the three values and names ‘pending’ as the default, so that is what this section follows. If someone corrects you with the other page, this is why.

onInvalid and Focus Management

onInvalid runs after fields have been marked as touched, so the errors are already on screen when it fires. Combined with errorSummary(), a flat list of the fields currently in error, and focusBoundControl(), you get accessible “jump to the first problem” behaviour in three lines:

onInvalid: (field) => {
  const first = field().errorSummary()[0];
  first?.fieldTree().focusBoundControl();
}

One caveat that decides whether this actually works. On a native input, focusBoundControl() focuses the element. On a custom control it has no effect by default, because a custom control may contain several native inputs and Angular will not guess which one you meant. You opt in by implementing a focus method:

export class MyInput implements FormValueControl<string> {
  readonly value = model('');
  private readonly el = viewChild.required<ElementRef<HTMLInputElement>>('el');

  focus() {
    this.el().nativeElement.focus();
    this.el().nativeElement.select();
  }
}

Reactive Forms never had an answer for this at all. Every codebase grew its own querySelector(‘.ng-invalid’) hack, and every one of them broke the moment a custom control wrapped its input in a div.

Additional Submit Actions

When one form needs more than one submit path, save versus submit for approval, the standalone submit() helper takes the same options object and returns a Promise<boolean>:

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

protected async requestApproval(): Promise<void> {
  const ok = await submit(this.loginForm, {
    action: async (field) => this.store.requestApproval(field().value()),
    ignoreValidators: 'none',
  });
}

If action is already defined in the form’s submission config, you can omit it here and override only what differs.

Submitting State

You can use submitting() to block UI:

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

submitting reads as true on descendants of a submitting field:

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

Resetting the Form

The reset() method clears interaction state (touched, dirty):

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

Optionally you can pass a new value:

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

Note: reset() doesn’t change value if you don’t pass it. It only resets UI state.

The mirror image is markAsTouched(), which takes a skipDescendants option. It defaults to false, so calling it on a section node marks that node and everything under it. Handy in a wizard, where you want to reveal the errors for the step the user is leaving without touching the steps they have not reached yet:

wizardForm.stepOne().markAsTouched();                        // step and its fields
wizardForm.stepOne().markAsTouched({ skipDescendants: true }); // just the node

Debouncing

For fields where we don’t want to react to every keystroke (e.g. search, async validation), we have 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 can also pass your own debounce function:

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

Instead of a number you can pass ‘blur’, which holds the model update until the user leaves the field:

debounce(f.query, 'blur');

One behaviour changes the mental model of debounce(field, 300) more than the number suggests: when a field becomes touched, the framework flushes the pending value to the model immediately, regardless of the remaining delay. Native inputs become touched on blur. So the debounce governs typing, not leaving the field.

Do not confuse this with the debounce option on validateAsync() and validateHttp(). debounce() delays updates to the model itself, which affects everything downstream: computed values, other validators, the template. The validator option delays only that one validator. If your goal is “stop hammering the endpoint”, the validator option is the narrower and usually better tool.

Custom Controls – End of ControlValueAccessor

In Reactive Forms creating a custom form control required implementing ControlValueAccessor, an interface with four methods, a magical provider with forwardRef, and manual calling of onChange / onTouched. Every Angular developer knows this 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 reduce this to one line.

FormValueControl – Minimalistic Contract

To create a control compatible with the [formField] directive, you just need 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 all. One model() signal and the control is ready to use:

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

The [formField] directive automatically synchronizes value between form and control. Change in form updates value(). Change in control updates the form model.

Optional Inputs – Automatic State Binding

FormValueControl defines a number of optional inputs. If you declare them, the [formField] directive will automatically fill them:

@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)="touch.emit()"
      />
      @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 - FormField will automatically bind if they exist
  readonly disabled = input(false);
  readonly touched = input(false);       // input, not model
  readonly touch = output<void>();       // emit on blur
  readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
  readonly invalid = input(false);
  readonly name = input('');
  readonly required = input(false);
  readonly readonly = input(false);
}

Full list of optional inputs:

  • disabled – whether field is disabled
  • disabledReasons – reasons carried from disabled({ when }) returning a string
  • readonly – whether field is read-only
  • hidden – whether field is conditionally hidden
  • touched – whether user interacted with field
  • dirty – whether value was changed
  • invalid – whether validation failed
  • pending – whether async validation is in progress
  • errors – list of validation errors
  • name – field name in form
  • required – whether field is required
  • min, max, minLength, maxLength, pattern – values from validators

Two details about these. min and max follow the control’s value type rather than always being numbers, so on a FormValueControl<string> they are typed string | undefined.

pattern is the odd one out. It is typed readonly RegExp[], not a single expression, because Signal Forms allows several pattern() rules on one field. For the same reason it is the only constraint the directive does not mirror onto the native HTML attribute, which accepts exactly one expression. required, min, max, minlength and maxlength are mirrored; pattern is not.

touched is an input, not a model, so your control receives the touched state but cannot write to it. Reporting interaction back to the form is the job of a separate output, touch. Emit it in response to blur, when focus leaves the control, not on focus. Blur-based rules such as debounce(‘blur’) depend on it.

Note that errors carries ValidationError.WithOptionalFieldTree entries rather than bare errors, because an error may name a different field as its target, as validateTree() does.

You declare only those you need. The rest is ignored.

One constraint is easy to trip over. A control implementing FormValueControl must not declare a checked property, and a FormCheckboxControl must not declare a value property. The contracts type these as undefined precisely to make the mistake a compile error, so a toggle component that exposes both out of habit will not bind.

Parse Errors and transformedValue

There is a case the value/validation split does not cover: raw input that cannot be turned into a value at all. Typing letters into a number field, or a half-finished date, is not “a number that failed validation”, it is not a number yet. Signal Forms models this separately as a parse error, and it is why <input type=”date”> behaves the way it does mid-typing.

transformedValue() is the API for controls that edit one representation and store another. A currency field backed by a number, for example:

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

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

  protected readonly rawValue = transformedValue(this.value, {
    // model value -> what the user sees
    format: (value: number) => value.toFixed(2),

    // what the user typed -> model value, or a parse error
    parse: (raw: string) => {
      const parsed = Number(raw.replace(',', '.'));
      if (Number.isNaN(parsed)) {
        return { error: { kind: 'parse', message: 'Enter a number' } };
      }
      return { value: parsed };
    },
  });
}

The whole design sits in the shape returned from parse. It is an object with two optional properties: value and error. Omit value and the model is simply not updated, which is how a half-typed entry avoids writing garbage into your state. Return error and the field reports it.

Two behaviours follow from that and are worth stating plainly. A field holding a parse error is invalid and blocks submission exactly like a failed validation rule, so you do not need a separate guard for it. And reset() clears pending parse errors and reformats the raw value from the model, which is what makes a reset field look clean rather than keeping the user’s rejected input on screen.

The returned signal also exposes parseErrors if you want to render them separately from validation errors.

Field Metadata

Validators are not the only thing that can travel with a field. metadata() attaches arbitrary typed data to a path, read back through the field state, with keys created by createMetadataKey() or createManagedMetadataKey(). This is how you carry things like a help text id, an analytics label, or a layout hint without smuggling them into the model.

FormCheckboxControl – For Checkboxes

For checkbox-type controls there is a separate FormCheckboxControl contract:

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);
}

Signal Forms eliminate ceremony. Instead of implementing an interface with four methods and configuring providers, you declare one signal and the control works.

Before You Start – A Few Notes

Status: Stable

Signal Forms left experimental status in Angular 22. The path from preview to stable was unusually fast, driven by internal case studies at Google on real form-heavy applications, and the API changes along the way (the submission config, parse errors, the when option) read like the output of that process rather than redesign for its own sake.

Worth being precise about the official line, because it is more cautious than the enthusiasm around the release. The documentation says Signal Forms work best in new applications built with signals, and that reactive forms remain a solid choice for existing applications or where you need production stability guarantees. The stated prerequisite is Angular v21 or higher, while the “stable since v22.0” markers apply to individual API entries.

My own view, and you should read it as that: for a greenfield project I would not start with Reactive Forms today. But that is a preference, not what the docs tell you to do.

Import

Signal forms live in their own entry point:

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

Everything related to interop with Reactive Forms lives in a second one:

import { compatForm, SignalFormControl, NG_STATUS_CLASSES } from '@angular/forms/signals/compat';

Keep these separate from @angular/forms imports unless you are deliberately bridging the two worlds.

Summary

Signal forms are not an evolution of Reactive Forms. They are a rethought from scratch implementation of forms in Angular. Key changes:

  • Model as source of truth – form and data are always synchronized
  • Real typing – TypeScript knows everything, without compromises
  • Reactivity out of the box – validators react to dependency changes without manual binding
  • One API – FormField and FormRoot instead of a zoo of directives
  • Schemas – reusable validation rules, including Zod and Valibot through Standard Schema
  • Simple Controls – FormValueControl instead of ControlValueAccessor

Should you migrate existing applications? You no longer have to answer that as a single yes-or-no. SignalFormControl lets you move one control at a time inside a FormGroup you never touch, and existing CVA-based components keep working either way. The realistic policy is not a migration project. It is a rule: when a control’s validation starts to hurt, convert that control.

And new projects? There’s no dilemma here. Signal forms are the future of forms in Angular.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.