An Overview of Signal Forms

Currently marked as experimental, Signal Forms targets non-production applications, as the API may shift without warning. This was evident in 21.0.0-next.8 where the Control type was changed to Field.

Signal Forms has become one of the most anticipated additions to the Angular framework, and we got our first look at an experimental implementation with the latest beta of Angular version 21. This new approach is set to transform form development. It cuts down the boilerplate required by both Template and Reactive Forms, while making validation, submission, and custom control creation much more straightforward.

The core principle behind Signal Forms is that they are model driven. You define a signal model and hand it off to the form function.

protected readonly userProfile = signal<UserProfile>({
    // model properties
})

Think back to custom controls in the past — integrating them into forms meant implementing the ControlValueAccessor interface so they could talk to the Forms or Reactive Forms Module. That complexity has been dramatically reduced, and several other long-standing pain points in custom control development have also been addressed, as we'll demonstrate shortly.

Let's begin with the setup. First, we define our model.

type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
}

With the model in place, we construct the form by creating a signal from it and wrapping it with the new form function.

export class User {
    protected readonly userProfile = signal<UserProfile>({
    	firstName:'',
        lastName:'',
    	phone:'',
    	email:'',       
    })
    
    protected readonly userForm = form(this.userProfile);
}

That's all there is to it. Compare that to the boilerplate of Template or Reactive Forms.

Internally, Signal Forms are represented as a FieldTree and FieldState, a hierarchical structure of your form. Here's a visual representation.

// our model
type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
}

// user (FieldTree root)
//  ├─ firstName (FieldState)
//  ├─ lastName (FieldState)
//  ├─ phone (FieldState)
//  └─ email (FieldState)

And for a model with nested objects, the tree would look something like this:

// our model
type userProfile = {
  firstName: string;
  lastName: string;
  phone: string;
  email: string;
  address: {
    street: string;
    city: string;
  }
}

// user (FieldTree root)
//  ├─ firstName (FieldState)
//  ├─ lastName (FieldState)
//  ├─ phone (FieldState)
//  ├─ email (FieldState)
//  └─ address (FieldTree node)
//       ├─ street (FieldState)
//       └─ city (FieldState)

The fundamental difference from its predecessors is that Signal Forms keep no separate copy of the data. When you modify a FieldState within the tree, you are directly mutating the original model object.

Each FieldState corresponds to an individual form field and its associated properties, such as value, validity, and dirty status.

Time to wire up our components. The examples here utilize Angular Material.

<mat-form-field>
      <mat-label for="firstName">First name</mat-label>
      <input
        [field]="userForm.firstName"
        id="firstName"
        matInput
        type="text"
        placeholder="First name"
      />
</mat-form-field>

Binding a model property to a template element is achieved with the new [field] directive, which takes the target field as its input. It's straightforward and provides two-way data binding instantly.

It's worth noting that in 21.0.0-next.8, the [control] directive was renamed to [field].

// for reference - the array for the dropdown to iterate over
// address: [] = [
//     { value: '0', viewValue: 'Primary' },
//     { value: '1', viewValue: 'Billing' },
//     { value: '2', viewValue: 'Shipping' },
//   ];

<mat-form-field>
    <mat-label for="address">Address</mat-label>
    <mat-select id="address" [field]="userForm.address">
      @for (addr of address ; track address.value() ) {
      <mat-option [value]="addr.value">
          {{addr.viewValue}}
      </mat-option>
      }
    </mat-select>
</mat-form-field>

Creating a drop-down selection is equally simple.

Let's shift our focus to validation. The form function accepts a second parameter which can be a schema, a function, or a set of form options. If a schema is passed as the second argument, then the form options are supplied as a third.

// recap what our model looks like
type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
}

protected readonly userForm = form(this.userProfile, (path)=>{
    required(path.firstName),
    required(path.lastName),
    email(path.email)
});

This second parameter may also be a function that takes a fieldPath as its argument, allowing you to define validation rules within it.

The order in which validators are declared holds no significance.

The standard validators are now exported from forms/signals and closely mirror those found in Template and Reactive Forms:

  • Email
  • Max
  • MaxLength
  • Min
  • MinLength
  • Pattern
  • Required

To apply a validator, you set its path to the specific fieldState you're targeting. In the template, iterating over the resulting errors object displays any messages.

 <mat-form-field>
      <mat-label for="email">Email address</mat-label>
      <input
        id="email"
        type="email"
        matInput
        [field]="profileForm.email"
        placeholder="Email"
        required
      />

      @if(profileForm.email().errors().length > 0) {
      <mat-error>
        @for(error of profileForm.email().errors(); track error) {
        <div>
        	Error message goes here...    
        </div>
        }
     </mat-error>
    }
 </mat-form-field>

While that's functional, listing many errors can make your template verbose. A more elegant solution is to attach messages directly within the form configuration, like so:

// recap what our model looks like
type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
}

protected readonly userForm = form(this.userProfile, (path)=>{
    required(path.firstName, {message: 'This is a required field.'}),
    required(path.lastName, {message: 'This is a required field.'}),
    email(path.email, {message: 'The email address is not valid.'})
});

Then, in the HTML, you can simply access error.message:

@if(profileForm.email().errors().length > 0) {
   <mat-error>
        @for(error of profileForm.email().errors(); track error) {
           <span>{{ error.message }}</span>
        }
   </mat-error>
}

One quirk I noticed with Angular Material—and this could stem from `Signal Forms' experimental status—is that mat-error wouldn't show up under the input unless I enclosed it in an @if block. I'm optimistic this will be resolved in future releases, but it's a necessary workaround for now.

Writing repetitive validation code isn't ideal. While our example only repeats it twice, imagine doing this for three, six, or nine controls. Fortunately, there's a better way: schemas. By creating a schema and applying it to your form, you can negate the need for duplication. Let's refactor our code to use this pattern.

const profileSchema: Schema<string> = schema((path) => {
  required(path, { message: 'This is a required field.' });
  minLength(path, 3, { message: 'This needs to be more than three characters'});
});
protected readonly userForm = form(this.userProfile, (path)=>{
   apply(path.firstName, profileSchema);
   apply(path.lastName, profileSchema );
   email(path.email, {message: 'The email address is not valid.'})
});

We use the apply function to attach our schema to the fields that need validation. This single line applies both required and minLength to firstName. Schemas are composable, so you can create specific ones for different sets of controls, like a separate schema that only applies minLength.

Creating Custom Validators

Beyond the built-in validators, we can define our own. For instance, let's create a validator that ensures a phone number field contains only digits. We'll write a function that accepts a path and an optional options object.

export function numericOnly(
  path: FieldPath<string>,
  options?: { message?: string }
): void {
 
  validate(path, (ctx) => {
    const value = ctx.value();

    if (!/^\d+$/.test(String(value))) {
      return customError({
        kind: 'phone',
        value: true,
        message: options?.message || 'Phone must contain only numbers.',
      });
    }
    return customError({
      kind: 'phone',
      value,
    });
  });
protected readonly userForm = form(this.userProfile, (path)=>{
   // other validators
   numericOnly(path.phone);
});

Validation Based on Conditions

Signal Forms also handles conditional validation with ease. Let's extend our model with an emailMarketing flag. This way, the validation rules for the email field are only enforced when the corresponding checkbox is checked.

type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
    emailMarketing: boolean;
}

protected readonly userForm = form(this.userProfile, (path)=>{
   required(path.email, { 
       when: ({ valueOf }) => valueOf(path.emailMarketing) === true, 
       message: 'This is a required field.',
   });
   email(path.email, {message: 'The email address is not valid.'})
});

We can add a required validator for the email path and utilize the when property in the configuration options. This property allows us to check the valueOf another field—in our scenario, we want the validation to activate when emailMarketing is true.

Remember to remove any overarching required validation schemas, as they will take precedence and also apply.

Handling Form Submission

For sending data to the server, Signal Forms provides a new function, appropriately named submit. It takes two arguments: your form and a function returning a promise. This promise should resolve to undefined upon a successful backend save. If it fails, you return an array of objects. Each object allows you to specify the error's kind (in our case, server), the specific field to attach the error to, and finally, the error message for display.

 onSubmit() {
    submit(this.userProfile, async (form) => {
      try {
        this.userProfileService.saveForm(form); // call to API to save our form data
        this.userProfile().reset(); 
        return undefined;
      } catch (error) {
        return [
          {
            kind: 'server',
            field: this.profileForm.firstName,
            message: (error as Error).message,
          },
        ];
      }
    });
  }

Calling .reset() on the form will only reset the pristine, dirty, and touched states. To reset the form's values back to their initial state post-submission, you'll need to reset the model values themselves.

During submission, it's common practice to disable the save button. The form() function exposes a submitting() signal specifically for this purpose.

<button
  [disabled]="!profileForm().valid() || profileForm().submitting()"
  (click)="submit()">
  matFab
  extended
  class="toggle-btn"
  type="button"
Save
</button>

In Reactive Forms, we set up our templates like <form (ngSubmit)="submit($event)"> ... </form>. In the current experimental state of Signal Forms, this isn't fully fleshed out. The feature's road-map has some details and proposed solutions for handling this going forward.

Building Custom Controls

The days of implementing the ControlValueAccessor interface for custom controls are over. We now have a much simpler interface that requires just one property, down from four methods. This new interface, FormValueControl<>, requires us to define a value property on our component, which must be a model(). Let's illustrate this with an example:

// our component 
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';

@Component({
  selector: 'star-rating',
  imports: [MatIconModule],
  template: `
  @if(required()){
  	<span class="required-asterisk">*</span>
  }
    <div class="star-rating">
      @for (star of stars; track $index) {
        <mat-icon 
          class="star"
          [class.filled]="star <= value()"
          (click)="setRating(star)"
          (mouseenter)="!disabled() && (hoverRating = star)"
          (mouseleave)="hoverRating = 0">
          {{ (hoverRating >= star || value() >= star) ? 'star' : 'star_border' }}
        </mat-icon>
      }
    </div>
  `,
})
export class StarRatingComponent implements FormValueControl<number> {
  value = model(0);
  disabled = input(false);
  required = input(false);
    
  stars = [1, 2, 3, 4, 5];
  hoverRating = 0;
  
  setRating(rating: number) {
    if (!this.disabled()) {
      this.value.set(rating);
    }
  }
}

By adopting the new FormValueControl interface, we simply add a value property of type modelSignal to our component, and that's it. Inspecting the FormValueControl interface reveals it extends FormUiControl, providing access to various optional properties like required and disabled. To leverage these in your component, you include them as inputs and configure the states in the parent's form.

<star-rating [field]="form.starRating" />
type ProductProfile = {
    name:string;
    description:string;
    price:number;
    rating:number;
    leaveReview: boolean;
}

protected readonly productProfile = signal<ProductProfile>({
   	name:'',
    description:'',
   	price:0,
   	starRating:0,
    leaveReview: false
})

protected readonly productForm = form(this.productProfile, (path) => {
 	required(path.starRating),
    disabled(path.starRating,({valueOf})=> valueOf(path.leaveReview) === true)
 });

That's all we need to give our form the expected required and disabled behaviors. In our example, the starRating control remains disabled until the leaveReview field is toggled to true.

Summary

Signal Forms is poised to be a revolutionary addition to Angular application development once officially released. We hope this post has offered a valuable glimpse into its capabilities. Despite its experimental status, I've been deeply impressed with how fully-featured it already is, and it's bound to improve even more in the coming months.

Signal Forms — figure 1
Angular University - High Quality Angular Courses