Angular Signal Forms - Part 1

In Angular v21, developers will have a new way, experimental for now, to build forms: Signal Forms.

After years of working with template-driven forms (ngModel) and reactive forms (formGroup/formControl), we now have a third approach that's entirely based on signals, available in the @angular/forms/signals package.

Angular logo

This is the first part of our series on Angular Signal Forms. In this article, we'll explore the basics: creating forms, handling submissions, and adding validation.

Let's dive in! 🚀

Creating a form with form()

As this new way is based on signals, the first thing to do in your component is to define a signal that will hold the value of the form. Then you can create a FieldTree to edit the value of this signal, using the form() function from the @angular/forms/signals package.

Here I want to write a Login component, where the user inputs their credentials:

// 👇 signal to hold the form credentials
private readonly credentials = signal({
  login: '',
  password: ''
});
// 👇 form created from the credentials signal (type is `FieldTree`)
protected readonly loginForm = form(this.credentials);

Then we need to bind each input/select/textarea to a field in the form. This is done using a single directive, Field:

<form (submit)="authenticate($event)">
  <label for="login">Login</label>
  <input id="login" [field]="loginForm.login" />
  <label for="password">Password</label>
  <input id="password" [field]="loginForm.password" />
  <button type="submit">Log in</button>
</form>

That's it! With just the [field] directive, your inputs are automatically bound to the form fields. A change on an input updates the corresponding property of the signal, and a change to the signal updates the input value.

Submitting the form with submit()

To submit the form, you can listen to the native submit event on the form element and call a method of your component.

In this method, you can then use the submit() function from the @angular/forms/signals package to handle the submission of the form. This function:

  1. Marks all fields as touched
  2. Checks the form validity
  3. If valid, calls your provided async action with the form as an argument

The argument is the same object, a FieldTree, as the loginForm returned by the form() function.

The FieldTree object is a key concept in signal forms. Your loginForm is a FieldTree, and so are loginForm.login and loginForm.password.

A FieldTree, like a Signal, is an object that is also a function. If you call it (loginForm()), it returns a FieldState object with several signal properties that represent the state of the field:

  • value(): the current value of the field
  • touched(): whether the field has been touched or not
  • dirty(): whether the field is pristine or not
  • disabled(): whether the field is disabled or not
  • disabledReasons(): if the field is disabled, the reasons why
  • hidden(): whether the field is hidden or not
  • readonly(): whether the field is readonly or not
  • submitting(): whether the field is being submitted

Some properties are related to validation, a topic we cover below:

  • pending(): whether the field is pending (async validators running)
  • valid(): whether the field is valid (all validators passed). Note that valid() is false until all async validators are done.
  • invalid(): whether the field is invalid
  • errors(): the errors of the field (if any)
  • errorSummary(): the errors of the field and its sub-fields (if any)

It also has a few methods to change the state of the field:

  • reset() to mark the field as pristine and untouched (but does not reset the value)
  • markAsTouched()/markAsDirty() to mark the field as touched/dirty.

We can also grab the FieldState of a sub-field using loginForm.login() for example, and each property then represents the state of that sub-field (its value, dirtiness, etc).

So, to come back to our form submission, we can write:

protected async authenticate(event: SubmitEvent) {
  // 👇 prevents the default browser behavior
  event.preventDefault();
  // 👇 submits the form if it's valid by calling the authenticate method (a promise)
  await submit(this.loginForm, form => {
    const { login, password } = form().value();
    return this.userService.authenticate(login, password);
  });
}

Note that the function that you pass to submit() must return a Promise. So if your service returns an Observable, you'll have to convert it to a promise using, for example, the firstValueFrom() function from rxjs.

Let's now add some validation to our form.

Adding validation with built-in validators

Angular provides a validation system based on functions that can be used to programmatically constrain the value of a field.

As with the previous form systems, there are two types of validators:

  • synchronous validators, that run immediately
  • asynchronous validators, that return a Promise and only run if all the synchronous validators on the field pass

The framework itself provides some built-in synchronous validators, the same as those it provides for reactive and template-driven forms:

  • required(field) to mark a field as required
  • minLength(field, length) to set a minimum length (for strings and arrays)
  • maxLength(field, length) to set a maximum length (for strings and arrays)
  • min(field, min) to set a minimum value (for numbers)
  • max(field, max) to set a maximum value (for numbers)
  • email(field) to validate an email address
  • pattern(field, pattern) to validate a value against a regex pattern

The functions take the field to validate as the first argument, and other arguments depending on the validator.

Let's build another Register form, allowing the user to create an account:

protected readonly accountForm = form(
    signal({
      email: '',
      password: ''
    }),
    // 👇 add validators to the fields, with their error messages
    form => {
      // email is mandatory
      required(form.email, { message: 'Email is required' });
      // must be a valid email
      email(form.email, { message: 'Email is not valid' });
      // password is mandatory
      required(form.password, { message: 'Password is required' });
      // should have at least 6 characters
      minLength(form.password, 6, {
        // can also be a function, if you need to access the current value/state of the field
        message: password => `Password should have at least 6 characters but has only ${password.value().length}`
      });
    }
);

Each validator will then run when the value of the field changes. If the validation fails, a ValidationError is added to the errors() property of the field. A ValidationError is an object with several properties:

  • kind: the kind of error (for example required, minLength, etc)
  • field: the field that caused the error
  • message: an optional human-readable message. There is no message by default, you need to provide it yourself when applying the validator if you want one.

Some validators can add more properties to the error, for example, the minLength validator adds a minLength property to the error, so you can access it when displaying the error message.

These errors can be displayed in the template by iterating over the errors() property of the field:

<input id="email" [field]="accountForm.email" />
@let email = accountForm.email();
@if (email.touched() && !email.valid()) {
<div id="email-errors">
  @for (error of email.errors(); track error.kind) {
    <div>{{ error.message }}</div>
  }
</div>
}

The built-in validators do more than they used to: they also add a property on the field. This property can be useful to know if a validator is applied to a field. For example, in the template, you can add an asterisk next to the label of required fields:

<label for="email">
  <span>Email</span>
  @let isEmailRequired = accountForm.email().metadata(REQUIRED);
  @if (isEmailRequired()) {
    <span>*</span>
  }
</label>

The metadata(REQUIRED) method returns a signal, and its value can be true or false. But some validators store additional details in this metadata. For example, the signal returned by metadata(MIN_LENGTH) contains the minimum length.

Standard schema validation with validateStandardSchema()

In addition to the previous built-in validators, signal forms offer a new way to define the field's constraints, by using a schema validation.

A few libraries have made this popular lately, like Zod or Valibot.

Both allow you to define a schema to represent your data, using functions to define the type of each field, and to add constraints to these fields. These libraries and others even joined their effort to define a standard: Standard Schema, which provides a common interface, called StandardSchemaV1, that libraries can use.

Angular relies on this standard and offers a validateStandardSchema() function which accepts such a StandardSchemaV1 schema.

This schema can be defined with whatever library you prefer. Let's use Zod to define a validation schema for our Register form:

(form) => {
  validateStandardSchema(
    form,
    z.object({ email: z.email(), password: z.string().min(6) })
  );
};

Here, we don't define error messages. The errors will be generated automatically by the validateStandardSchema() function which returns errors of type StandardSchemaValidationError. These errors have the same kind, field properties as the previous ValidationError, but also an issue property containing a message.

You can display it like this:

<input id="email" [field]="accountForm.email" />
@let email = accountForm.email();
@if (email.touched() && !email.valid()) {
<div id="email-errors">
  @for (error of email.errors(); track error.kind) {
    @if (isStandardSchemaError(error)) {
      {{ error.issue.message }}
    }
  }
</div>
}

with the help of the following type-guard:

isStandardSchemaError(error: ValidationError): error is StandardSchemaValidationError {
  return error.kind === 'standardSchema';
}

For example, if you input a too short password, you'll get an error like this:

Too small: expected string to have >=6 characters

This can be customized as well, as Zod allows defining error messages when defining the schema, using z.string().min(6, { error: issue => `Password should have at least ${issue.minimum} characters` }) for example.

What's Next?

We've covered the fundamentals of Angular Signal Forms: creating forms, handling submissions, and adding basic validation with built-in validators or using schema validation with libraries like Zod.

In the next part, we'll explore more advanced topics: like creating custom validators, cross-field validation, and asynchronous validation.

Stay tuned for Part 2!

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!


Picture of Cédric Exbrayat
Cédric Exbrayat

← Older post