> ## Content Index
> Fetch the complete content index at: https://www.angularspace.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Signal Forms
- URL: https://www.angularspace.com/signal-forms/
- Published: 2026-01-19T12:39:14.000Z
- Updated: 2026-01-21T09:23:54.000Z
- Author: Duncan Faulkner

Experimental - This is intended for use in non-production applications, as the API can change (without notice) as it did with 21.0.0-next.8 where Control was renamed to Field.

Signal Forms is one of the most talked-about features to be added to the Angular framework, and we recently got to see an experimental version of this with the recent beta release of Angular version 21\. Signal Forms will be a game-changer when it comes to developing forms. Not only does it simplify form creation by removing a significant amount of boilerplate code that Template and Reactive Forms require, but it has also made form validation, form submission and creating custom controls significantly easier.

Signal Forms are `model driven`, we create a `signal` model and then pass this to the `form` function as an argument.

```typescript
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.

```typescript
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.

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

```

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 `FieldTree`and `FieldState` this is a hierarchical structure of our form and looks like this.

```typescript
// 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:

```typescript
// 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.

```html
<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.

In version 21.0.0-next.8 \[control\] was renamed to \[field\].

```html
// 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).

```typescript
// 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.

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.

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

```

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:

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

```

And in the HTML we can read the `error.message` like this:

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

```

Because I'm using Angular Material (and this might just because it's still experimental at present), but to get the `mat-error` to work and display the error message correctly under the `input` I had to wrap an `@if` block around the `mat-error`, hopefully this will not be the case in later releases but is necessary at the moment.

I don't like 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.

```typescript
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'});
});

```

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

```typescript
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,
    });
  });

```

```typescript
protected readonly userForm = form(this.userProfile, (path)=>{
   // other validators
   numericOnly(path.phone);
});

```

## 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).

```typescript
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'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 emailMarketing checkbox is ticked.

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

## Form Submission

When it comes to submitting our form to the server, we have a new function 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.

```typescript
 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 the `.reset()` on the form only resets the `pristine`, `dirty` and `touched` to reset the form values after submitting reset the model values.

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.

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

```

In Reactive forms we would set up our forms like: `<form (ngSubmit)="submit($event)"> ... </form>` at present this isn't fully fleshed out in Signal Forms, there is a some information on the [road-map](https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=127969774&ref=angularspace.com) about it and possible solutions.

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

```typescript
// 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](https://next.angular.dev/api/forms/signals/FormUiControl?ref=angularspace.com) this provides many optional properties for instance, [required](https://next.angular.dev/api/forms/signals/required?ref=angularspace.com) and [disabled](https://next.angular.dev/api/forms/signals/disabled?ref=angularspace.com) 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`.

```html
<star-rating [field]="form.starRating" />

```

```typescript
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.

![](https://storage.ghost.io/c/3f/63/3f6333b8-1f83-4017-8426-91bfbc264d81/content/images/2026/01/image.png)

[ ![Angular University - High Quality Angular Courses](https://d3vigmphadbn9b.cloudfront.net/banners/angular-university-banner-4.jpg) ](https://angular-university.io/?ref=angularspace.com)