← Back to blog

April 7, 2026

Signal Forms Series — Part 1 | Part 2 (this post)

This is a follow up post on Signal Forms I wrote back in October 2025 which you can find medium or on Angular Space. This post was written while Angular…

Signal Forms Series — Part 1 | Part 2 (this post)

Signal Forms — Updated

This is a follow up post on Signal Forms I wrote back in October 2025 which you can find medium or on Angular Space. This post was written while Angular 21 itself was still in Beta and Signal Forms was (and still is for the moment) experimental.

Experimental :- This is intended for use in non-production applications, as the API can change (and without notice) as it did with 21.0.0-next.8 where Control was renamed to Field, since then it has subsequently been renamed again and is now formField.

A fair bit has changed since I wrote that first article, Angular 21 become stable (and at the time of writing is at 21.2.0). Signal Forms is currently still experimental but has received a lot of improvements and enhancements. In this article I go through those changes and update the code from the first article and highlight what the changes are.

Just to recap. What are Signal Forms? Signal Forms is a major step forward and is a real game changer in the reactive forms arena. Up to this point we’ve had Template and Reactive Driven Forms, both require a fair bit of boiler plate code to set up and can be quite difficult to learn.

We still create a Signal Form as we previously did, we create a signal model then pass this to the form function as an argument.

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

For example, in the past when creating custom controls that will be used in our forms, we’ve had to implement the ControlValueAccessor interface to allow our custom controls to integrate with the Forms or Reactive Forms Module, the good news is this has been greatly simplified as well, along with a few other issues we faced when creating custom controls, which we’ll look at in this post.

Let’s walk through setting up a Signal Form, we start off by creating our model.

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

Next, let’s build our form, we create a signal of our model and then using the new form function to create a wrapper around our model.

export class User {
    protected readonly userProfile = signal<UserProfile>({
        firstName:'',
        lastName:'',
        phone:'',
        email:'',       
    });
    
    protected readonly userForm = form(this.userProfile);  
    
    // or if we want to create an initial value for our model if we want to reset
    // the form once it's been saved, for example
    
  private readonly INITIAL_VALUES = {
    firstName: '',
    lastName: '',
    phone: '',
    email: '',
    marketing: false,
    address: { street: '', city: '' },
  };
    
  protected readonly userProfile = signal({
        ...this.INITIAL_VALUES
    });
}

That’s all we need to create a Signal Forms — how easy was that compared to Template or Reactive forms.

Signal Forms are represented as a FieldTreeand FieldState this is a hierarchical structure of our form and looks like this.

// our model
type UserProfile = {
    firstName:string;
    lastName:string;
    phone:string;
    email:string;
}// user (FieldTree root)
//  ├─ firstName (FieldState)
//  ├─ lastName (FieldState)
//  ├─ phone (FieldState)
//  └─ email (FieldState)

If we had a nested model it would be represented 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 main difference of Signal Forms compared to Template or Reactive forms is that Signal Forms doesn’t maintain a copy of the data, so when we update a FieldState in the tree, we are directly mutating the original model.

A FieldState represents an individual form field including it's state (value, validity, dirty status etc...).

Now, let’s have a look at connecting our input components to our new form. I’m using Angular Material in these examples. The first change is that [field] is now [formField]

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

To connect our model and template, we need to use the new [field] method, passing in a field that we want this input to be bound to. This is nice and simple, and we get two-way data binding out of the box.

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

Setting up a drop-down list is just as easy.

Let’s now look at validation. The form function takes a second parameter, which can be a schema or a function, or form options (if a schema is passed as the second argument, the form options can be passed in as a third option).

// 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 is a function and takes a fieldPath as an argument, in this function we set up our validation.

Note:- The ordering in which validation is applied doesn’t matter.

The built-in validation is now imported from forms/signals and we have a similar list two what we have in Template or Reactive Forms:

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

With the validation, we set the path to the fieldState we want the validation applied to. In the HTML we just need to iterate over the errors object.

<mat-form-field appearance="outline" style="width: 500px" subscriptSizing="dynamic">
    <mat-label for="firstName">First name</mat-label>
      <input
        [formField]="userForm.firstName"
        id="firstName"
        matInput
        type="text"
        placeholder="First name"
      />
      <mat-hint align="end">This is a test</mat-hint>
</mat-form-field>
@if (userForm.firstName().touched() && userForm.firstName().invalid()) {
  @for (error of userForm.firstName().errors(); track error) {
    <mat-error>{{ error.message }}</mat-error>
  }
}

Adding error messages like this could get a bit long with multiple error messages, so to help with this, we can add a message to the form like this:

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

I’m not a fan of repeating code unnecessarily, and the validation we have just created repeats (albeit, just twice in our example), but imagine if you have three, six, nine, or more controls; the that’s going to be repeated a lot. Luckily, there is another method that we can use to remove the duplication. For this, we need to create a schema, which can then be applied to our form. Let's adjust our code and create a schema.

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 apply our schema to the fields we want to validate. This will apply both required and minLength to the firstName, and we can create multiple schemas as necessary, for example we only want minLength validation on certain controls.

Custom validation

We can also create custom validators as well, let’s create a custom validator that makes the phone number control contains numbers only. We need to create a function that takes a path and an optional options

export function numericOnly(path: SchemaPath<string>, options?: { message?: string }): void {
  validate(path, (ctx) => {
    const value = ctx.value();
    if (value === '' || value === null || value === undefined) {
      return null;
    }if (!/^\d+$/.test(String(value))) {
      return {
        kind: 'numeric',
        message: options?.message || 'Phone must contain only numbers. ',
      };
    }
    return null;
  });
}

A few things have changed in this since the first post, first up the path was FieldPath and is now SchemaPath. To call this validator we also need to add it to the form validation section:

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.'}),
   numericOnly(path.phone)
});

The other change is customError is no longer required and we just return the object. In the original post this was like:

// this is just for reference and how it use to look/work 
if (!/^\d+$/.test(String(value))) {
      return customError({
        kind: 'phone',
        value: true,
        message: options?.message || 'Phone must contain only numbers.',
      });
    }
    return customError({
      kind: 'phone',
      value,
    });

Conditional validation

Signal Forms has you covered for that as well. Let’s adjust our model, lets add a emailMarketing flag to our model, so that the validation for email is only applied if the emailMarketing checkbox is ticked (true).

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

We’ll add a required validator and set a path to email, in the configuration options there is a when property and we can use this to check the valueOf another field, in our case we what to apply the validation when the marketing checkbox is ticked.

Note:- If you have applied a required validation schema you will need to remove this as it will also be applied.

Form submission

Prior to Angular 21.2.0, Signal Forms gave us a new way to submit our forms uses a new called… (you’ve guessed it) submit. This function takes two arguments: the first is our form and the second is a function that returns a promise or undefined if the save to our back end is successful. If it's not successful, we return an array of objects, and within this object, we can set the kind of error, here we're specifying server , if we want to attach the error to a specific control, we specify the field; and finally, we set the error message to be displayed.

onSubmit() {
    submit(this.userForm, async (f) => {
      const value = f().value();
      const result = await this.someService.createAccount(value);if (result.status === 'error') {
        const errors: ValidationError.WithOptionalFieldTree[] = [];if (result.fieldErrors.firstName) {
          errors.push({
            fieldTree: f.firstName,
            kind: 'server',
            message: result.fieldErrors.firstName,
          });
        }
        if (result.fieldErrors.email) {
          errors.push({
            fieldTree: f.email,
            kind: 'server',
            message: result.fieldErrors.email,
          });
        }
        return errors.length ? errors : undefined;
      }
      return undefined;
    });
  }

With this submit function we would have also had to have to override the forms default submit behaviour and created an event to submit the form.

<form (ngSubmit)="submit($event)"> ... </form>

As of Angular 21.2.0 the submit function is still valid, but we have another way to submit forms, and with this new submission configuration of the form function, we don't need to override the submit or even create a click event. We have a new directive called formRoot and we add this to our form element.

<form [formRoot]="userForm">
    ...
</form>

and that’s all we need to do in the template. In the TypeScript file in our form function we have a new submission configuration. In this object we supply the same form submission code that we have used in the submit event (above), the formRoot directive handles the submitting of the form, marking all fields as touched so any validation errors are displayed.

protected readonly userForm = form(
    this.userProfile,
    (path) => {
      (apply(path.firstName, profileSchema),
        apply(path.lastName, profileSchema),
        required(path.email, {
          when: ({ valueOf }) => valueOf(path.marketing) === true,
          message: 'This is a required field.',
        }),
        email(path.email, { message: 'The email address is not valid.' }),
        numericOnly(path.phone));
    },
    {
      submission: {
        action: async (f) => {
          const value = f().value();
          const result = await this.userService.saveForm(value);if (result.status === 'error') {
            const errors: ValidationError.WithOptionalFieldTree[] = [];if (result.fieldErrors.firstName) {
              errors.push({
                fieldTree: f.firstName,
                kind: 'server',
                message: result.fieldErrors.firstName,
              });
            }
            // omitted lastName, telephone, street, city for brevity
            if (result.fieldErrors.email) {
              errors.push({
                fieldTree: f.email,
                kind: 'server',
                message: result.fieldErrors.email,
              });
            }
            return errors.length ? errors : undefined;
          }
          return undefined;
        },
      },
    },
  );

Calling the .reset() on the form only resets the pristine, dirty and touched, to reset the form values after submitting we need to reset the model values. To do this we can pass an initial value object that resets the fields back to the default state.

submission: {
        action: async (f) => {
          ...
          
          f().reset({ ...this.INITIAL_VALUES });
          return undefined;
        }
 }

When submitting a form, we usually disable the save button while this operation takes place. The form() function exposes a submitting() signal that we can use to disable our save button.

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

Custom controls

When creating custom controls we no longer need to implement the ControlValueAccessor interface 🎉, we now have a simpler new interface, the good news is we only need to implement one property and not four methods as before, this new interface is called FormValueControl<> and we need to set the value property our component (and it must be called value), which must be a model(). Let's create 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);
    }
  }
}

With the new FormValueControl interface, we just need to include the value property in our component; it also has to be of type modelSignal, and that's all we need to set. If we look at the FormValueControl interface we can see that it extends FormUiControl this provides many optional properties for instance, required and disabled and for our component to make use of these we just need to include them in our component and in the parent component we just need to set the states in the 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)
 });

This is all we need to have the required and disabled properties for our form to work as we'd expect, the starRating is in a disabled state until the leaveReview is set to true.

Conclusion

Signal Forms is going to be a game-changer for creating forms in Angular applications when it’s released. Hopefully, this post has given some insight into how to use it. I’ve been really impressed by how complete it is (even though it’s currently experimental), and over the next few weeks and months, this will only get better.


Originally published on Medium.