SCROLL TO EXPLORE •SCROLL TO EXPLORE •
A quick look at <mark>Angular Signal Forms</mark>

A quick look at Angular Signal Forms

A brief, practical tour of the new Signal Forms in Angular—setup, validation, template binding, and submission.

Maksym Byelous - 5 min read

Introduction

It's very exciting for me, to be honest. I’ve been waiting for Signal Forms for a long time already. Earlier this year, when I started a new project that was all about forms, I wished Signal Forms had already been released. Sometimes, perfection takes time and in this case it was 100% worth the wait.

Today, I’ll do a quick look into the basics to get a feeling for Signal Forms.

What we'll cover

  • Initiate the form
  • Set initial value
  • Set up validation
  • Connect form to template
  • Track value changes
  • Submit the form

Reactive Forms vs Signal Forms

With Reactive Forms, which I love, creating a proper form you might use FormBuilder and describe all the controls with initial values. In many cases you get some data object you can use to set values of form controls. This process often has two parts: create the form definition itself, then set values with data you get from some API. And of course, to make it work, the data model and the form need to match. There’s nothing wrong with that, but who doesn’t like a bit of magic?

Define the data model

Let's say we create a small form to add new users. Initially, our component with raw data would look like this:

interface User {
  firstName: string;
  lastName: string;
  email: string;
  newsletter: boolean;
}

const newUserValue: User = {
  firstName: '',
  lastName: '',
  email: '',
  newsletter: false,
};

Create the form from the model

Defining the form is quite an easy process, because we only need to describe all controls and set initial values if we already have a data object matching our form. One line will do the job:

import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { Field } from '@angular/forms/signals';

@Component({
  selector: 'app-register-user',
  templateUrl: './register-user.html',
  imports: [Field],
})
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);
  protected readonly userForm = form(this.newUser); // Tada form is initialised
}

And we have all properties of newUserValue initialized as form controls. Easy as breathing.

Add validation

It's never that simple - Lets add some validation to our form. For example, let’s use the built-in email validator:

import { email } from '@angular/forms/signals';
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);

  protected readonly userForm = form(this.newUser, (path) => {
    email(path.email, {
      message: 'Enter a valid email',
    });
  });
}

A validator is just a function that needs a path to a form control. This path is accessible through the form function.

Have a required property? Easy:

import { required } from '@angular/forms/signals';
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);

  protected readonly userForm = form(this.newUser, (path) => {
    required(path.email, {
      message: 'Email is required',
    });
  });
}

We can also add specific error messages on the fly.

Conditional validators

What else is common for us to do with validation? Apply some validators conditionally. Here's how it looks for Signal Forms:

import { required } from '@angular/forms/signals';
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);

  protected readonly userForm = form(this.newUser, (path) => {
    required(path.email, {
      when: ({ valueOf }) => valueOf(path.newsletter) === true,
      message: 'Email is required',
    });
  });
}

This feels very natural and straightforward. And this scenario looks much better than the logic you’d create for a Reactive Form. If it stays like this for a production feature, I’ll be very happy.

Validation schemas (DRY)

Often we have fields with the same list of validators, and Signal Forms provide nice tools to deal with such situations. So—DRY—and use a validation schema.

In our example, I want firstName and lastName to be required fields with a minimum length of 3 characters. We should create a schema for it:

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

const nameValidationSchema: Schema<string> = schema((path) => {
  required(path, { message: 'Information is required' });
  minLength(path, 3, { message: 'Too short' });
});

Then apply it to specific controls:

import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { apply, required, email } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);

  protected readonly userForm = form(this.newUser, (path) => {
    apply(path.firstName, nameValidationSchema);
    apply(path.lastName, nameValidationSchema);

    required(path.email, {
      when: ({ valueOf }) => valueOf(path.newsletter) === true,
      message: 'Email is required',
    });
    email(path.email, {
      message: 'Enter a valid email',
    });
  });
}

Connect the form to the template

It's time to use a bit of HTML and connect our form to the real world. Let's add some layout with inputs, error messages, and a submit button. Check this example:

<form (submit)="addUser($event)">
  <input
    [field]="userForm.firstName"
    placeholder="Enter first name"
    type="text"
  />
  
  @let fistNameControl = userForm.firstName();
  @if (fistNameControl.touched()) {
    @for (error of fistNameControl.errors(); track error) {
      <div class="error-message">{{ error.message }}</div>
    }
  }

  <!-- ...other fields... -->

  <button
    type="submit"
    [disabled]="userForm().submitting() || (!userForm().valid() && userForm().touched())"
  >
    Save
  </button>
</form>

Notes

  • Bind the control to the input: [field]="userForm.firstName".
  • Import the Field directive from Signal Forms: import { Field } from '@angular/forms/signals';.
  • In the Signals world, the control and all its properties are signals, so don’t forget brackets to extract the value (the Field directive takes the signal as-is, so no brackets there).

Submit the form

Add a submit function to add a user:

import { submit } from '@angular/forms/signals';
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);
  protected readonly userForm = form(this.newUser);

  addUser(event: Event) {
    event.preventDefault();

    if (!this.userForm().valid()) {
      return;
    }
    
    submit(this.userForm, async (form) => {
      try {
        await fetch('https://dummyjson.com/users/add', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(form().value()),
        });

        this.newUser.set(newUserValue);
        this.userForm().reset();
        return undefined;
      } catch (e) {
        return [
          {
            kind: 'server',
            message: (e as Error)?.message ?? 'Oops',
          },
        ];
      }
    });
  }
}

This is an async example, but you can do something more straightforward if you like. One thing that currently doesn’t work as expected for me is the form reset function. It doesn’t clear the values of controls but just resets the state of the form (like validity). So for now, if you need to clear values, you should do it yourself. I expect this to improve in the future.

Track value changes

One more thing we often do is keep track of some control value changes. Doing this with a subscription looks like overkill. Since these are signals, you can use computed signals or the effect function (maybe untracked). This helps reduce subscriptions in your code.

For example, here's a dynamic greeting for a new user:

@if (newUserGreeting()) {
  <h2>{{ newUserGreeting() }}</h2>
} @else {
  <h2>Let's add a new user!</h2>
}

Under the hood, this is a computed signal dependent on one control’s value changes:

import { computed } from '@angular/core';
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

@Component({ selector: 'app-register-user' })
export class RegisterUser {
  protected readonly newUser = signal<User>(newUserValue);
  protected readonly userForm = form(this.newUser);

  newUserGreeting = computed(() => {
    const name = this.userForm.firstName().value();
    return name ? `Welcome to the new world, ${name}!` : '';
  });
}

Wrap-up

Of course, this is not everything, so stay tuned for my next blog post where I’ll dive deeper into custom (async) validators, nested forms, and form arrays. It all looks promising and should make the new Angular application environment clearer, more stable, and more efficient. Don’t wait until the production stage—try it now, feel it, and give feedback to the contributors.

Resources

Photo of Maksym Byelous

Maksym Byelous

Senior Software Engineer