Le blog
de
Ninja Squad

Angular Signal Forms - Part 2

Welcome back to our series on Angular Signal Forms! In Part 1, we covered the basics: creating forms, handling submissions, and adding validation.

In this second part, we'll explore all the advanced features: custom validators, cross-field validation, asynchronous validation, dynamic field behavior, debouncing form updates, custom form components, sub forms, and other powerful capabilities that make Signal Forms a compelling choice for complex form scenarios.

Angular logo

Custom validation with validate() and validateTree()

You can write your own custom validators, either synchronous or asynchronous, and apply them with validate().

Let's start with a synchronous validator, that checks that the password of our Register form is strong enough.

A validator is simply a function that takes a ChildFieldContext as an argument, and returns either undefined if the field is valid, or a ValidationError if the field is invalid.

The ChildFieldContext provides useful methods to get the value or state of the field (as well as the value or state of other fields, more on that later).

Validators are typed, which is a great improvement over previous form systems.

// password is strong enough
validate(form.password, (context: ChildFieldContext<string>) =>
  isTooWeak(context.value()) ? { kind: 'too-weak', message: 'Password is too weak' } : undefined
);

It is also possible to add cross-field validation, by adding the validator to the parent field, and checking the values of the sub-fields.

// password is different from email
validate(form, context => {
  const { email, password } = context.value();
  return email && password && email === password
    ? {
        kind: 'password-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

In your template, you can display this cross-field validation error by checking the errors on the form itself:

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

Alternatively, you can put the cross-field validation error on a specific field, using validateTree() instead of validate(). This function works like validate(), but allows returning errors targeting specific sub-fields of the tree. For example, to validate that the password is different from the email, but show the error on the password field:

validateTree(form, context => {
  const { email, password } = context.value();
  return email && password && email === password
    ? {
        // 👇 targets the password field
        field: context.field.password,
        kind: 'password-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

It is also possible to add a validation to a field and get the value of another field by using the valueOf() method. The same example can be written like this:

// 👇 the validator is defined on the password field
validate(form.password, context => {
  // 👇 get the value of the email field
  const email = context.valueOf(form.email);
  const password = context.value();
  return email && password && email === password
    ? {
        kind: 'is-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

Extracting and applying schemas with apply()

To avoid duplicating validation logic across forms, you can extract it into reusable schemas, that can then be applied to fields using the apply() function.

If both our login and password fields should be required and with a minimum length, we can extract this logic into a schema:

const requiredAndMinLengthSchema = schema<string>(field => {
  required(field, { message: 'This field is required' });
  minLength(field, 3, { message: 'Minimum length is 3 characters' });
});

Then we can apply this schema to both fields in our forms:

protected readonly accountForm = form(this.user, context => {
  // 👇 apply the same schema to both fields
  apply(context.login, requiredAndMinLengthSchema);
  apply(context.password, requiredAndMinLengthSchema);
});

A schema can also be applied to all elements of an array using applyEach():

protected readonly eventForm = form(
  signal({
    location: '',
    participants: ['ced'] as Array<string>
  }),
  context => {
    // 👇 apply the same schema to all participants
    applyEach(context.participants, requiredAndMinLengthSchema);
  }
);

By the way, to display an array of fields in the template, all you need is a plain old @for loop. No need for any special directive.

@for (participant of eventForm.participants; track participant) {
  <div class="participant">
    <label [for]="`participant-${$index}`">Username</label>
    <input [id]="`participant-${$index}`" [field]="participant" />
    @if (participant().touched() && !participant().valid()) {
      <div [id]="`participant-${$index}-errors`">
        @for (error of participant().errors(); track error.kind) {
          <div>{{ error.message }}</div>
        }
      </div>
    }
  </div>
}

applyEach() also works with objects, applying the schema to all properties. For example, if we want to make all fields required:

protected readonly loginForm = form(
  signal({
    login: '',
    password: ''
  }),
  context => {
    // 👇 apply the required schema to all fields
    applyEach(context, requiredSchema);
  }
);

Asynchronous validation with validateAsync() and validateHttp()

The validation can also be asynchronous thanks to the validateAsync() function, which uses a Resource:

// email is not already registered (async validation)
validateAsync(form.email, {
  params: (email: ChildFieldContext<string>) => email.value(),
  factory: (params: Signal<string | undefined>) =>
    resource({
      // 👇 Params contains the `email` signal and is used to trigger the resource
      params,
      // the loader makes an HTTP call to check if the email is already registered
      loader: async (loaderParams: ResourceLoaderParams<string | undefined>) =>
        // returns true if the email is already registered
        await this.userService.isRegistered(loaderParams.params)
    }),
    // 👇 This is called with the result of the resource
    onSuccess: (response: { isRegistered: boolean }) =>
      response.isRegistered
        ? {
            kind: 'email-already-registered',
            message: 'Email is already registered'
          }
        : undefined,
    // 👇 This is called if the resource fails
    onError: () =>
      ({
        kind: 'email-check-failed',
        message: 'Could not verify if the email is already registered'
      })
});

The resource is automatically called when the value of the field changes, and the field is marked as pending() while waiting for the result of the resource. When the resource resolves, if the onSuccess function returns an error, the error is added to the errors() of the field.

As an alternative, if you need to call an HTTP endpoint to validate a field, you can use a httpResource() with the validateHttp() function:

// email is not already registered (async validation)
validateHttp(form.email, {
  // 👇 httpResource is triggered when the email signal changes
  request: (email: ChildFieldContext<string>) => `/api/users/check?email=${email.value()}`,
  onSuccess: (response: { isRegistered: boolean }) =>
    response.isRegistered
      ? {
          kind: 'email-already-taken',
          message: 'Email is already taken'
        }
      : undefined,
  onError: () =>
    ({
      kind: 'email-check-failed',
      message: 'Could not verify if the email is already taken'
    })
});

In that case, you only need to provide the request URL.

Server errors

The submit function we saw earlier has another interesting feature: if the action you provide returns errors, they are automatically added to the errors() of the corresponding fields (or to the form if you don't provide a field).

await submit(this.loginForm, async form => {
  const { login, password } = form().value();
  try {
    await this.userService.authenticate(login, password);
    return;
  } catch (error) {
    // 👇 add an error to the form
    const message = (error as Error).message;
    return [{ kind: 'invalid-credentials', message }];
  }
});

Dynamic behavior

A more realistic form often has some dynamic behavior, for example, enabling/disabling or hiding/displaying fields based on the value of other fields, or adding/removing validators.

To hide a field, you can use the hidden() function, which lets you specify when the field should be hidden or not, based on the state of the form. When a field is hidden, it is excluded from validation. In the template, you can then use an @if block to display the field only if it's not hidden.

Similarly, to disable a field, you can use the disabled() function in the form schema. Here, the password field is disabled unless the login is valid:

protected readonly loginForm = form(this.credentials, f => {
  required(f.login);
  // 👇 disable the password field until a valid login is provided
  disabled(f.password, ({ stateOf }) => {
    return !stateOf(f.login).valid();
  });
});

You can go even further and return a string from the disabled logic, in order to populate the disabledReasons() of the field.

The last function to know about is readonly(), which makes a field read-only as you imagine. This is useful when you want to display an input that the user may not edit, but that should still be submitted with the form. disabled() and readonly() automatically update the HTML attributes of the corresponding field in the template: you don't need to do anything special for an input to be disabled in the template part of the form.

NOTE: Disabled fields do not behave the same way as in reactive forms: their value is still part of the form value when submitting it. The positive side is that you don't have to deal with the fact that all properties are potentially undefined. The negative side is that you need to be careful before sending this value to the server: it could be invalid if it comes from a disabled field. The same goes for hidden fields.

These functions can be used together to create complex dynamic behavior, and they can be applied conditionally. The second parameter of the form() function is not a reactive function, which means you can't use if/else conditions based on signals. You need to use applyWhenValue()/applyWhen() to apply these functions conditionally.

For example, to make a field mandatory only when a user is not admin (and let's say this info is stored in a signal called isAdmin):

protected readonly accountForm = form(this.user, form => {
  // 👇 the birth year field is required only when the user is not admin
  applyWhenValue(
    form,
    () => !this.userService.isAdmin(),
    form => {
      required(form.birthYear, { message: 'Birth year is required for non-admin users' });
    }
  );
});

If the logic depends on another field, you can use applyWhen() instead, which gives you access to the form and its fields via valueOf/stateOf:

protected readonly accountForm = form(this.user, form => {
  // 👇 the birth year field is required only when the isAdmin checkbox is not checked
  applyWhen(
    form,
    context => !context.valueOf(form.isAdmin),
    form => {
      required(form.birthYear, { message: 'Birth year is required for non-admin users' });
    }
  );
});

Debouncing form updates with debounce()

Sometimes you want to control when form control changes are synchronized to the form model. For example, you might want to delay validation while a user is still typing, to avoid triggering expensive validation checks on every keystroke.

Signal forms provide a debounce() function that allows you to manage the timing of form field updates:

protected readonly accountForm = form(this.user, form => {
  // 👇 let's say the computation is expensive, and we want to debounce it
  debounce(form.password, 500);
  // password is strong enough
  validate(form.password, (context: ChildFieldContext<string>) =>
    isTooWeak(context.value()) ? { kind: 'too-weak', message: 'Password is too weak' } : undefined
  );
});

When a debounce rule is applied to a field, the value signal may lag behind the controlValue signal. The controlValue represents the current value in the form control (what the user just typed), while value represents the debounced value that gets synchronized to the form model.

The debounce delay can be a fixed number (in milliseconds) or a function that returns a promise.

Custom form components with FormValueControl and FormCheckboxControl

Sometimes we need to build custom form components. We used to have ControlValueAccessor for that in previous form systems, but with signal forms, there is a new and simpler way to do that: by implementing the FormUiControl interface, or, more accurately, one of its children interfaces FormCheckboxControl (for boolean-like controls) and FormValueControl (for other types of values).

Note: Custom form components that use ControlValueAccessor are still supported in signal forms, but it's recommended to use these new interfaces for new components.

These interfaces only define the inputs and models your component should have, and Angular will bind the Field directive state into these signals automatically.

The properties to implement are:

  • value: (only for FormValueControl) a model input defining the value of the field
  • checked: (only for FormCheckboxControl) a model input defining whether the field is checked or not
  • touched: model input defining whether the field has been touched or not
  • disabled/disabledReasons/readonly/hidden/dirty: inputs containing the state of the field
  • errors/invalid/pending: inputs containing the validation state of the field
  • required/minLength/maxLength/min/max/pattern: inputs containing the built-in validators applied to the field

All these properties are optionals, except value or checked depending on the interface you implement. Most properties are read-only inputs, except value, checked, and touched, which are model inputs, as the component is expected to update them.

Let's build a Rating component, that allows the user to select a rating from 1 to 5. The value will be a number, so let's implement the FormValueControl<number> interface:

@Component({
  selector: 'app-rating',
  template: `
    @let v = value();
    @for (pickableValue of pickableValues; track pickableValue) {
      <button
        [class.selected]="v != null && pickableValue <= v"
        type="button"
        (click)="value.set(pickableValue)"
        [disabled]="disabled()"
        (blur)="touched.set(true)"
      >
        {{ pickableValue }}
      </button>
    }
  `,
  styles: ['.selected { background-color: yellow }']
})
export class Rating implements FormValueControl<number | null> {
  readonly value = model<number | null>(null);
  readonly touched = model(false);
  readonly disabled = input(false);

  protected readonly pickableValues = [0, 1, 2, 3, 4, 5];
}

The template is straightforward. We iterate over the possible ratings and display a button for each rating. Clicking a button updates the value. And when it loses focus (blur event), we mark the field as touched.

Changing the value of the model inputs automatically updates the state of the field in the form. And in the other direction, changing the field state automatically update the inputs of the component.

Our component can then be used in a form like this:

<app-rating id="rating" [field]="movieForm.rating" />

Sub forms

Sometimes you don't want to define the entire form in a single component. You may want to split it into several sub-forms, each defined in its own component. With signal forms, this is easy to do by passing a field to the child component.

Let's say our user form has an address sub-form:

protected readonly user = signal({
  firstname: '',
  lastname: '',
  address: {
    number: '',
    street: '',
    zipcode: '',
    city: ''
  }
});
protected readonly userForm = form(this.user, f => {
  required(f.firstname, { message: 'First name is required' });
  required(f.lastname, { message: 'Last name is required' });
  // 👇the address schema can be defined separately and reused
  apply(f.address, addressSchema);
});

We can define an Address component that accepts a FieldTree for the address:

@Component({
  selector: 'app-address-form',
  template: `
    @let address = addressField();
    <div>
      @let number = address.number;
      <label for="number">Number</label>
      <input id="number" [field]="number" />
      @if (number().touched() && number().invalid()) {
        <div>
          @for (error of number().errors(); track error.kind) {
            <div>{{ error.message }}</div>
          }
        </div>
      }
    </div>
    <input id="street" [field]="address.street" />
    <input id="zipcode" [field]="address.zipcode" />
    <input id="city" [field]="address.city" />
  `,
  imports: [Field]
})
export class AddressForm {
  readonly addressField = input.required<FieldTree<AddressModel>>();
}

Then we can use our Address component in the user registration template:

<app-address-form [addressField]="userForm.address" />

That makes it really easy to split a form into several components!

Conclusion

Signal forms bring a new way to build forms in Angular, based on signals and with a fresh approach to validation and reactivity. While still experimental for now, they already offer a lot of interesting features that make building forms easier and more enjoyable than before.

Among the benefits, the type safety is better than before; cross-field validation is easier to implement (no need to use FormGroup anymore); and building custom form components is simpler as well (FormValueControl is much simpler than ControlValueAccessor).

We lose the ng-valid/ng-dirty/... CSS classes that were automatically added to form controls, but we can easily recreate them using the properties of the FieldTree, even if it requires a bit more work in the template.

A few things would be great to have in the future, like better support for displaying error messages in the template (iterating over errors() is a bit low-level for most use cases).

What's next?

We've now covered the comprehensive features of signal forms: from creating forms and handling submissions to custom validators, asynchronous validation, server error handling, dynamic field behavior, debouncing form updates, custom form components, and sub forms.

Key takeaways from this series:

  • Signal forms provide a new signal-based approach to forms in Angular
  • They offer better type safety and easier cross-field validation than previous form systems
  • Custom validators are simple functions using validate() that return validation error objects
  • Asynchronous validation uses validateAsync() or validateHttp()
  • Server errors can be automatically mapped to form fields
  • Dynamic behavior is achieved via applyWhen() and applyWhenValue()
  • Form updates can be debounced using debounce() to control the timing of synchronization
  • Custom form components use FormValueControl, which is simpler than ControlValueAccessor
  • Sub forms can be easily created by passing fields to child components

Signal forms are still experimental but show great promise for making form development more intuitive and type-safe in Angular applications!

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

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 (may be debounced)
  • controlValue(): the current value in the form control (always up-to-date)
  • 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 Mini to define a validation schema for our Register form:

(form) => {
  validateStandardSchema(
    form,
    z.object({ email: z.string().check(z.email()), password: z.string().check(z.minLength(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 Mini allows defining error messages when defining the schema, using z.string().check(z.minLength(6, { message: 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!