Home | Send Feedback | Share on Bluesky |

Deep dive into Angular Signal Forms

Published: 1. August 2026  •  angular

Angular 22 stabilizes the signal-based forms API called Signal Forms, which was introduced experimentally in Angular 21. This API provides automatic synchronization between a model signal and form controls, type-safe field access and bindings, and schema-based validation. Signal Forms does not replace either of the existing APIs; it provides a third option, especially for applications that use signals throughout the codebase.

This article explores the Signal Forms API and covers the following topics:

Table of contents

Getting started

Form model

The Signal Forms API stores form data in a writable signal. The first step is to create a signal() that holds the form model. This is a regular writable signal. Structural nodes in the model must be plain objects or arrays; classes, Map, and Set are not supported as structural nodes. Leaf values can include Date for date-based native inputs, and custom controls can support other leaf types they understand. Every field should have an initial value.

  readonly guestbookModel = signal<GuestbookModel>({
    name: "Heidi Debugger",
    email: "heidi@example.ch",
    canton: "LU",
    yodelVolume: 4,
    fondueReady: true,
    visitDate: new Date("2026-08-01T00:00:00.000Z"),
    alarmTime: "07:15",
    postcard: "The marmot reviewed my pull request.",
  });

guestbook-demo.ts

Avoid optional properties and values set to undefined; an undefined property is treated as an absent field and is not added to the field tree.

Initialize every field required in the field tree. The empty string '' or another default string value works for native text inputs, while false or true works for checkboxes. Use 0 when zero is a meaningful initial value and number | null when a native number input may initially be empty. Use Date | null for native date inputs that may initially be empty, [] for an empty array, and {} for an empty object.

At submission time, the application can read the entire model signal with guestbookModel(). Direct updates to the model signal are also supported:

guestbookModel.update((model) => ({ ...model, name: 'Ada' }));

This update also propagates to the connected form input.


Form field tree

Pass the model signal to the form() function to create a Signal Form. This function returns a field tree that mirrors the model shape. The field tree is the main API for reading and writing form data, validation state, and interaction state.

The second argument to form() is a schema callback. The callback receives a path object that mirrors the model shape. Use it to attach validation and availability rules to fields and subtrees. The rules are reactive: they run whenever the model changes.

The following example attaches built-in validators from @angular/forms/signals to the guestbook fields. Each validator has a user-facing message that appears in the UI when validation fails.

The Signal Forms API includes built-in validators for the most common use cases. You can also create custom validators for cases that are not covered by the built-in rules.

  readonly guestbookForm = form(this.guestbookModel, (path) => {
    required(path.name, {
      message: "Enter a guest name.",
    });
    email(path.email, {
      message: "Enter a valid email address.",
    });
    min(path.yodelVolume, 1, {
      message: "Yodel volume must be at least 1.",
    });
    max(path.yodelVolume, 10, {
      message: "Yodel volume must not exceed 10.",
    });
    required(path.visitDate, {
      message: "Select a visit date.",
    });
    minDate(path.visitDate, new Date("2026-07-01T00:00:00.000Z"), {
      message: "The guestbook opens on 1 July 2026.",
    });
    maxDate(path.visitDate, new Date("2026-09-30T00:00:00.000Z"), {
      message: "The guestbook closes on 30 September 2026.",
    });
    maxLength(path.postcard, 140, {
      message: "The postcard must not exceed 140 characters.",
    });
  });

guestbook-demo.ts

The field tree is not a copy of the model signal and does not store its own values. Instead, it wraps the model signal with a rich API for reading and writing values, validation state, and interaction state. The field tree always reads the value from the model signal and writes updates back to it. The field tree is reactive: when the model signal changes, the field tree updates automatically.

For example, guestbookForm.name().value() reads the current value of the name property from the model signal. Setting a value with guestbookForm.name().value.set('Ada') updates the underlying model signal and notifies all subscribers.


Binding native controls

With the model signal and field tree in place, native controls can be bound to form fields. The Signal Forms API provides a [formField] directive that binds a native control to a field in the tree. The directive handles two-way synchronization between the DOM control and the field tree.

Import the FormField directive from @angular/forms/signals in the component that owns the template:

import {
  email,
  form,
  FormField,
  max,
  maxDate,
  maxLength,
  min,
  minDate,
  required,
} from "@angular/forms/signals";

guestbook-demo.ts

@Component({
  selector: "app-guestbook-demo",
  imports: [Errors, FormField, JsonPipe],
  templateUrl: "./guestbook-demo.html",
})

guestbook-demo.ts

Then use the [formField] directive in the template to bind native controls to the form fields. The directive takes a field from the field tree as its value.

  <form class="grid two" novalidate>
    <label>
      Guest name
      <input [formField]="guestbookForm.name" />
    </label>
    <label>
      Email
      <input type="email" [formField]="guestbookForm.email" />
      <app-errors [field]="guestbookForm.email"></app-errors>
    </label>
    <label>
      Canton
      <select [formField]="guestbookForm.canton">
        @for (canton of cantons; track canton) {
        <option [value]="canton">{{ canton }}</option>
        }
      </select>
    </label>
    <label>
      Yodel volume
      <input type="number" [formField]="guestbookForm.yodelVolume" />
    </label>
    <label>
      Visit date
      <input type="date" [formField]="guestbookForm.visitDate" />
      <app-errors [field]="guestbookForm.visitDate"></app-errors>
    </label>
    <label>
      Alarm time
      <input type="time" [formField]="guestbookForm.alarmTime" />
    </label>
    <label class="check">
      <input type="checkbox" [formField]="guestbookForm.fondueReady" />
      Fondue-ready
    </label>
    <label class="span">
      Postcard
      <textarea [formField]="guestbookForm.postcard"></textarea>
    </label>
  </form>

guestbook-demo.html

For native controls, the Signal Forms API uses the existing model value to choose the appropriate representation. A number or range field modeled as number | null receives a number or null. Native date, month, time, and week fields can use strings in their native formats, such as YYYY-MM-DD or HH:mm, or they can use Date | null.

Text inputs and textareas use strings, while a checkbox uses a boolean. Model a set of independent checkboxes as separate boolean fields. Radio buttons that represent one choice bind the same field tree to every option. The Signal Forms API gives them the same generated name and stores the selected element's string value.

<input type="radio" value="pickup" [formField]="permitForm.trip.delivery">
<input type="radio" value="sled" [formField]="permitForm.trip.delivery">
<input type="radio" value="cable-car" [formField]="permitForm.trip.delivery">

Field state

Every field in the field tree exposes a set of signals that describe its state. These signals are reactive and can be used in the template to show validation errors, disable buttons, or control visibility.

Method What it returns
value() The current model value for the field.
controlValue() The current UI control value. This can differ from value() while a field update is debounced.
valid() true when the field and active descendants have no validation errors and no pending validators.
invalid() true when the field or an active descendant currently has at least one validation error.
touched() true after the control receives and loses focus, or after code or form submission marks it as touched.
dirty() true once an interactive field is modified through the UI. It remains true until reset, even if the value changes back.
pending() true while asynchronous validation is running.
submitting() true while a submit() action is running for this field or one of its ancestors.
name() A generated field name, based on the parent field name unless a root name option is provided.
required() true when active rules say the field is required.
disabled() true when an active disabled() rule disables the field. [formField] mirrors this state to the control.
disabledReasons() The structured reason objects contributed by active disabled() rules. Render reason.message.
hidden() true when a hidden() rule says the field should be treated as hidden.
readonly() true when a readonly() rule says the value can be viewed but not edited.
errors() The validation errors attached directly to this field.
errorSummary() The validation errors for this field and its descendants.
formFieldBindings() The bound UI controls for the field. Use it for advanced focus or control integration.

The root form is also a field, so guestbookForm().valid() aggregates child state. valid() is not the same as !invalid(). During asynchronous validation, both can be false because no error has been found yet, but the field is not confirmed valid either.

Built-in rules also publish constraint metadata. required(), min(), max(), minDate(), maxDate(), minLength(), and maxLength() are mapped to compatible native attributes. pattern() populates the field's pattern metadata but is not copied to the native pattern attribute.

These attributes provide native semantics and accessibility information, but the Signal Forms API does not use the browser's validity or validationMessage as its validation API. Read the field-state signals and errors instead.

Field state also includes these methods:

Method Use it for
value.set(next) Programmatically update a field value and the backing model signal. Setting the value programmatically does not mark the field as dirty.
getError(kind) Read the first direct error with a particular kind, such as field().getError('required'). Use it to render an individual error differently.
markAsTouched() Mark a field and its descendants as touched. Pass { skipDescendants: true } to touch only that field.
markAsDirty() Mark a field as dirty when a code-driven change should count as an interaction.
reset(value?) Clear touched and dirty state; optionally replace the field value at the same time.
reloadValidation() Re-run asynchronous validators for this field and its descendants after external data changes.
focusBoundControl() Focus the first bound UI control for this field. The focus-management section uses it after an invalid submission.

Validation

Validation is an important aspect of every form. User input must be checked for correctness, completeness, and consistency. The Signal Forms API provides a rich validation system that supports synchronous and asynchronous validators, conditional rules, and custom validation logic.

Validation rules are attached to the field tree in the schema callback. The Signal Forms API provides built-in validators for common use cases and supports custom validators for more complex scenarios. Validators are reactive, run whenever the model changes, and can depend on other fields in the form.

The callback itself runs once when the form is created. It does not run again when the model changes. The rules themselves are reactive and will re-evaluate whenever the model changes.

  readonly guestbookForm = form(this.guestbookModel, (path) => {
    required(path.name, {
      message: "Enter a guest name.",
    });
    email(path.email, {
      message: "Enter a valid email address.",
    });

guestbook-demo.ts

A custom validator returns null or undefined when validation succeeds. When validation fails, the Signal Forms API stores one or more structured error objects. Each error has a kind, an optional message, and a reference to the field tree where the error belongs.

{
  kind: 'required',
  message: 'Enter a guest name.',
  fieldTree: guestbookForm.name,
}

Built-in validators

The Signal Forms API includes these built-in synchronous validators:

Validator Use it for Notes
required(path) Values that must be present. null, '', and false all fail. An empty array passes; use minLength(path, 1) for a non-empty collection.
email(path) Text fields that should contain an email address. Use required() as well if the field cannot be empty.
min(path, value) Numeric values that must be at least a minimum. Also sets the native min attribute on compatible controls.
max(path, value) Numeric values that must be no more than a maximum. Also sets the native max attribute on compatible controls.
minLength(path, value) Strings or arrays whose length must meet a minimum. Also sets the native minlength attribute on compatible text controls.
maxLength(path, value) Strings or arrays whose length must meet a maximum. Also sets the native maxlength attribute on compatible text controls.
pattern(path, regexp) Strings that must match a regular expression. Publishes pattern metadata but does not set the native pattern attribute.
minDate(path, date) Date values that must be on or after a minimum date. Use this for model values that are Date objects, not raw native date-input strings.
maxDate(path, date) Date values that must be on or before a maximum date. Pairs with minDate() for date ranges stored as Date values.

Several validators also provide UI metadata. For example, min(path.trip.altitude, 1200) validates the field and lets [formField] set the matching native min attribute on the bound input.


Date and pattern validation

The guestbook stores the value from its native date input as Date | null. Its schema restricts visits to the 2026 summer season:

    required(path.visitDate, {
      message: "Select a visit date.",
    });
    minDate(path.visitDate, new Date("2026-07-01T00:00:00.000Z"), {
      message: "The guestbook opens on 1 July 2026.",
    });
    maxDate(path.visitDate, new Date("2026-09-30T00:00:00.000Z"), {
      message: "The guestbook closes on 30 September 2026.",
    });

guestbook-demo.ts

      <input type="date" [formField]="guestbookForm.visitDate" />
      <app-errors [field]="guestbookForm.visitDate"></app-errors>

guestbook-demo.html

minDate() and maxDate() both validate the field and publish the limits that [formField] writes to the native min and max attributes. The explicit UTC values match the calendar-date representation used by the native control.

The permit demo shows the other model shape: its startDate is a string such as 2026-09-12, which is the native YYYY-MM-DD representation.

The permit's emergency phone field is the runnable pattern() example:

    pattern(path.safety.emergencyPhone, /^(\+41|0)[0-9 ]{9,14}$/, {
      message: "Use a Swiss-looking phone number, e.g., +41 44 123 45 67.",
    });

permit-demo.ts

Unlike the date limits, this rule does not create a native HTML pattern attribute. It validates through Signal Forms and publishes an array containing the regular expression through permitForm.safety.emergencyPhone().pattern() so custom controls can consume it.


Validator configuration

The built-in validators can also accept a configuration object as their final argument.

    min(path.yodelVolume, 1, {
      message: "Yodel volume must be at least 1.",
    });

guestbook-demo.ts

The message property customizes the user-facing text without changing the built-in error kind. For example, a failing min() rule still produces kind: 'min'.

To replace the whole error object, use the error property. This is useful when a custom error kind is needed for a particular field.

min(path.age, this.minAge, {
  error: {
    kind: 'notOldEnough',
    message: `The minimum age is ${this.minAge} years.`,
  },
});

Displaying errors

Errors can be displayed in the template by checking the errors() signal for a field. The touched() signal is often used to avoid showing errors before the user has interacted with the field. Because a field can have multiple errors, the template typically iterates over the errors() array and renders each error's message.

@if (field().touched() && field().invalid()) {
<ul class="errors">
  @for (error of field().errors(); track $index) {
  <li>{{ error.message || error.kind }}</li>
  }
</ul>
}

errors.html

Another way of displaying errors is to check with getError(kind) for a specific error kind. This is useful when a particular error should be rendered differently from the others.

@if (field().touched() && field().getError('required')) {
  <span class="error">This field is required.</span>
}

Custom validators

The built-in validators cover the common cases, but applications often need special validation logic. The Signal Forms API provides two ways to create custom validators: validate() and validateTree(). Both accept a field path and a callback that returns null or undefined for success, or one or more structured error objects for failure.

Use validate() when a custom error belongs to the field being validated. The first argument is the field path, and the second argument is a callback that receives a field context. The field context provides value() for accessing the value of the field specified in the field path and valueOf(path) for reading other fields in the same form model.

With these two methods, it is easy to implement cross-field validation. For example, a permit form requires the user to confirm their password. The confirmPassword field must match the password field.

    validate(path.profile.confirmPassword, ({ value, valueOf }) =>
      value() === valueOf(path.profile.password)
        ? null
        : {
            kind: "passwordMismatch",
            message: "The passwords do not match.",
          },
    );

permit-demo.ts


Use validateTree() when a rule belongs to a group of fields. Like validate(), the first argument is a field path, and the second argument is a callback that receives a field context. When validation fails, the error object can include a fieldTree property that points to the field tree where the error should be attached.

    validateTree(path.profile, ({ valueOf, fieldTree }) => {
      const first = valueOf(path.profile.firstName).trim().toLowerCase();
      const last = valueOf(path.profile.lastName).trim().toLowerCase();
      return first && first === last
        ? {
            kind: "sameName",
            message: "First and last name cannot be identical.",
            fieldTree: fieldTree.lastName,
          }
        : null;
    });

permit-demo.ts

The confirm-password rule uses validate() because the error belongs on confirmPassword. The first-name/last-name rule uses validateTree() because the rule is about a relationship between siblings and deliberately attaches the error to lastName.


Conditional schemas

Sometimes a validation rule should only be active under certain conditions. For example, a trip permit may require a guide note only when the user indicates that they need a guide. The Signal Forms API provides applyWhen() and applyWhenValue() for conditional validation rules.

In this example, the required rule for guideNote is only active when needsGuide is true. The condition runs reactively, so the rule is added or removed automatically when the needsGuide checkbox changes.

    applyWhen(
      path.trip,
      ({ valueOf }) => valueOf(path.trip.needsGuide),
      (trip) => {
        required(trip.guideNote, {
          message: "Enter a note for the guide.",
        });
      },
    );

permit-demo.ts

The first argument is the schema path where the conditional rule group is attached. It also determines what the third callback receives. Here, the first argument is path.trip, so the callback parameter is the trip subtree.

The second argument is a condition callback that returns true when the rules should be active. It receives a field context that contains value() for the field being evaluated and valueOf(path) for any other field in the same form. In this example, the condition reads needsGuide from the same form model.

The condition runs reactively. When it returns true, the rules in the third callback are active. When it returns false, those rules are inactive and do not contribute validation errors. The condition is synchronous; if the decision depends on remote data, put that result into signal-backed state first.

applyWhenValue() is the specialized alternative for value predicates and TypeScript type guards. A static model shape retains previously entered values when an option changes in conditional sections. The official schema guide covers type narrowing with discriminated unions.


Availability rules

The hidden(), disabled(), and readonly() rules control whether a field is hidden, disabled, or read-only. They are reactive and can depend on other fields in the form. when is a callback that receives a field context.

In this example, guideNote is hidden when needsGuide is false. The coupon field is disabled when the altitude is below 2,000 meters, and the permit ID is always read-only.

    hidden(path.trip.guideNote, {
      when: ({ valueOf }) => !valueOf(path.trip.needsGuide),
    });

permit-demo.ts

    disabled(path.trip.couponCode, {
      when: ({ valueOf }) =>
        (valueOf(path.trip.altitude) ?? 0) < 2000
          ? "Coupons are available for trips at or above 2,000 meters."
          : false,
    });
    readonly(path.trip.permitId);

permit-demo.ts

The message that when returns is stored in disabledReasons() and can be rendered in the template. A false return value means the field is enabled. A true return value means the field is disabled without a message.

disabled() and readonly() are applied to native controls by [formField]. hidden() is programmatic state, so the template decides whether to render the field with @if.

Rule Visible Editable Participates in validation
hidden() No, if the template respects it No No
disabled() Yes No No
readonly() Yes No No

Reusable schemas

A reusable schema avoids duplicating validation rules. The schema() function takes a non-reactive callback whose path parameter is used to declare rules.

export const travelerNameSchema = schema<{
  firstName: string;
  lastName: string;
}>((path) => {
  required(path.firstName, {
    message: "Enter the lead traveler's first name.",
  });
  required(path.lastName, {
    message: "Enter the lead traveler's last name.",
  });
});

advanced-schemas.ts

The schema can then be applied to any path with the same shape. For example, a lead field with firstName and lastName properties can use the schema through the apply() function:

      apply(path.lead, travelerNameSchema);

advanced-desk-demo.ts


Asynchronous validation

A field's validity often cannot be determined locally. For example, a username may require a server check to determine whether it is already taken. The Signal Forms API supports asynchronous validation with validateHttp() and validateAsync().

Asynchronous validation runs only after synchronous validation passes.

The following example uses validateHttp() to check username availability against a backend API. It debounces the request to reduce network traffic during typing. The value 350 is the number of milliseconds to wait after the last keystroke before sending the request. The request callback returns the URL, while the onSuccess and onError callbacks handle the response.

As with all validators, the return value is either null for success or a structured error object. The onSuccess callback receives the parsed JSON response from the server, while onError runs when the request fails or returns a non-2xx status code.

    debounce(path.profile.username, 350);
    validateHttp(path.profile.username, {
      request: ({ value }) => `/api/usernames/${encodeURIComponent(value())}`,
      onSuccess: (available: { available: boolean }) =>
        available.available
          ? null
          : {
              kind: "usernameTaken",
              message: "This username is already taken.",
            },
      onError: () => ({
        kind: "usernameCheckFailed",
        message: "The username could not be checked. Try again.",
      }),
    });

permit-demo.ts

To show that a request is in progress, the template can check the pending() signal for the field:

        <label>
          Username
          <input [formField]="permitForm.profile.username" />
          @if (permitForm.profile.username().pending()) {
          <span class="hint">Checking the registry...</span>
          }
          <app-errors [field]="permitForm.profile.username"></app-errors>

permit-demo.html


validateAsync() supports non-HTTP asynchronous validation. This lower-level API accepts custom asynchronous validation logic. Its factory callback returns a resource that performs the work through an API such as IndexedDB, Web Workers, or WebSockets.

const VALID_COUPON_CODES = new Set(["ALPINE10", "SUMMIT20"]);

function abortableDelay(milliseconds: number, abortSignal: AbortSignal) {
  return new Promise<boolean>((resolve) => {
    if (abortSignal.aborted) {
      resolve(false);
      return;
    }
    const onAbort = () => {
      clearTimeout(timer);
      resolve(false);
    };
    const timer = setTimeout(() => {
      abortSignal.removeEventListener("abort", onAbort);
      resolve(true);
    }, milliseconds);
    abortSignal.addEventListener("abort", onAbort, { once: true });
  });
}

permit-demo.ts

    pattern(path.trip.couponCode, /^[A-Z]+[0-9]+$/, {
      message: "Use uppercase letters followed by digits.",
    });
    validateAsync(path.trip.couponCode, {
      when: ({ value }) => value().trim().length > 0,
      params: ({ value }) => value().trim().toUpperCase(),
      debounce: 300,
      factory: (coupon) =>
        resource({
          params: () => coupon(),
          loader: async ({ params, abortSignal }) => {
            const completed = await abortableDelay(400, abortSignal);
            return completed
              ? { accepted: VALID_COUPON_CODES.has(params) }
              : undefined;
          },
        }),
      onSuccess: (result) =>
        result.accepted
          ? null
          : {
              kind: "couponRejected",
              message: "This coupon code is not accepted.",
            },
      onError: () => ({
        kind: "couponCheckFailed",
        message: "The coupon code could not be checked. Try again.",
      }),
    });

permit-demo.ts

The synchronous pattern() rule runs first. The when condition skips blank values; otherwise, the validator waits 300 milliseconds, passes the normalized code to the resource, and maps the resource result to a form error. The factory is invoked once for the field, while its parameter signal changes with the input. In a real application, the loader could call IndexedDB, a Web Worker, a WebSocket service, or another asynchronous API.

The template exposes the resource state through the same pending() signal used by validateHttp():

        <label>
          Coupon code
          <input [formField]="permitForm.trip.couponCode" />
          @if (permitForm.trip.couponCode().disabled()) { @for ( reason of
          permitForm.trip.couponCode().disabledReasons(); track reason ) {
          <span class="hint">{{ reason.message }}</span>
          } } @if (permitForm.trip.couponCode().pending()) {
          <span class="hint">Checking the coupon registry...</span>
          }
          <app-errors [field]="permitForm.trip.couponCode"></app-errors>
        </label>

permit-demo.html


Debouncing form updates

Debouncing can be applied both as top-level field logic and in validator configuration. The top-level debounce(path, config) delays UI-to-model updates; a validator-level debounce delays only validation.

The top-level configuration accepts a delay in milliseconds, the special value 'blur', or a custom Debouncer. With 'blur', the control can display the current UI value while the form model remains unchanged until the control loses focus:

debounce(path.profile.username, 'blur');

Marking a field as touched immediately synchronizes any pending field-level debounced value. A blur normally marks the field as touched, and form submission marks all interactive fields as touched before validation. Pending UI values are therefore synchronized before validation runs and before the submission action is invoked.

For asynchronous and HTTP validators, Angular cancels a pending request when the field value changes. Returning undefined from request or params skips the operation.


With a validator-level debounce, the validator runs only after the specified delay. The field value is updated immediately, but validation is delayed. This is useful for expensive validation operations that should not run on every keystroke.

validateHttp(path.profile.username, {
  debounce: 300,
  request: (username) => `/api/username/${username}`,
  onSuccess: (response) => response.available ? null : { kind: 'usernameTaken', message: 'That username is already taken.' },
});

If a special debouncing behavior is needed, a custom Debouncer can be provided. A Debouncer is a function that receives a field context and an AbortSignal. It can return a promise that resolves when the debounce delay has passed. If the user types again before the delay expires, Angular aborts the previous invocation and calls the Debouncer again.

The following code demonstrates an adaptive debouncer. The username availability example can synchronize an empty value immediately, give users more time while the username is still very short, and react quickly when they shorten an existing username to correct it:

import { debounce, form, type Debouncer } from '@angular/forms/signals';

function waitForDebounce(
  milliseconds: number,
  abortSignal: AbortSignal,
): Promise<void> {
  return new Promise((resolve) => {
    let timeoutId: ReturnType<typeof setTimeout> | undefined;

    const onAbort = () => {
      clearTimeout(timeoutId);
      resolve();
    };

    timeoutId = setTimeout(() => {
      abortSignal.removeEventListener('abort', onAbort);
      resolve();
    }, milliseconds);
    abortSignal.addEventListener('abort', onAbort, { once: true });
  });
}

const usernameDebouncer: Debouncer<string> = (context, abortSignal) => {
  const currentUsername = context.value().trim();
  const nextUsername = context.state.controlValue().trim();

  if (nextUsername === '') {
    return; // Clear the model and availability result immediately.
  }

  let delay = 300;
  if (nextUsername.length < 3) {
    delay = 700;
  } else if (nextUsername.length < currentUsername.length) {
    delay = 150;
  }

  return waitForDebounce(delay, abortSignal);
};

readonly permitForm = form(this.permitModel, (path) => {
  debounce(path.profile.username, usernameDebouncer);
  // The validateHttp() username availability rule follows here.
});

context.state.controlValue() contains the latest text displayed by the input, whereas context.value() still contains the last value synchronized to the model. When the user types again before the delay expires, Angular aborts the previous invocation. Handling the provided AbortSignal clears that obsolete timer. This adaptive rule can replace the fixed debounce(path.profile.username, 350) from the availability example; the existing validateHttp() rule then runs only after the chosen delay.


Standard Schema validation

Libraries that implement the Standard Schema interface can be integrated with Signal Forms through validateStandardSchema(). Standard Schema provides a common interface for validation libraries such as Zod and Valibot, allowing a data model to be validated against a library-defined schema.


const swissPassSchema = z.object({
  swissPass: z.string().regex(/^CH-\d{4}$/, {
    error: 'Swiss pass must use the format CH-1234.',
  }),
});

validateStandardSchema(path, swissPassSchema);

Submission

The Signal Forms API provides two submission methods: manually with submit() or declaratively with FormRoot. Both methods mark interactive fields as touched, evaluate validation according to the submission options, and run the action only when submission is allowed. Hidden, disabled, and read-only fields are not marked as touched, and their validators do not gate submission. Their values remain in the form model and are not automatically omitted from the submitted data.

Manual submission

The submit() function, imported from @angular/forms/signals, accepts the field tree to submit and an options object. The options can include an action function that performs the submission, an onInvalid callback that runs when validation blocks submission, and an ignoreValidators setting that controls whether invalid or pending validators block submission.

submit() returns a Promise<boolean>. It resolves to true when the action runs without returning submission errors, and to false when validation blocks submission, a concurrent submission is skipped, or the action returns errors. It also marks interactive fields as touched and updates submitting(). If neither the call nor the form configuration supplies an action, it throws. If the action itself throws or returns a rejected promise, the submit() promise rejects.

const result = submit(this.permitForm, {
  action: async () => {
    // ...
  },
  onInvalid: () => {
    // ...
  },
  ignoreValidators: 'none',
});

The submission options are:

Option What it does
action The asynchronous work to run when the form may submit. Save data through an API, update local state, or return submission errors.
onInvalid Runs when validation blocks submission. Use it for focus management, summaries, or analytics.
ignoreValidators Controls whether invalid or pending validators block submission.

ignoreValidators has three modes:

Value Behavior
'pending' Default. Submit if there are no current validation errors; pending asynchronous validators do not block.
'none' Submit only when every validator has passed; pending asynchronous validators block.
'all' Submit even when validators are invalid or pending.

Angular's normal (submit) event provides manual control:

<form novalidate (submit)="submitPermit($event)">
  ...
</form>
  submitPermit(event: Event): void {
    event.preventDefault();
    submit(this.permitForm, {
      action: async () => {
        await new Promise((resolve) => setTimeout(resolve, 350));
        this.submitted.set(this.permitModel());
      },
      ignoreValidators: "none",
    });
  }

permit-demo.ts

State signals can disable the submit button while submission is running or validation is pending. The submitting() signal is true while the submit() action runs for the field or one of its ancestors. The pending() signal is true while asynchronous validation runs for the field or one of its descendants.

The following button is disabled while the form is submitting or while any asynchronous validation is pending:

      <button
        type="submit"
        [disabled]="permitForm().pending() || permitForm().submitting()"
      >
        Submit permit
      </button>

permit-demo.html


The submission action can be passed directly when no other options are required:

await submit(this.permitForm, async () => {
  await savePermit(this.permitModel());
});

The action receives the submitted field tree and a second object containing the root and submitted field trees. The current value of the submitted tree is available through submitted().value().

await submit(this.permitForm.profile, async (profileField, { root, submitted }) => {
  await saveProfile(submitted().value());
});

The difference between root and submitted is that root is the entire form, while submitted is the subtree passed to submit().


The action can also return validation errors from the server or a final domain check:

await submit(this.permitForm, {
  action: async () => {
    const result = await reserveUsername(this.permitModel().profile.username);
    return result.available
      ? undefined
      : {
          fieldTree: this.permitForm.profile.username,
          kind: 'usernameTaken',
          message: 'That username became unavailable during entry.',
        };
  },
  ignoreValidators: 'none',
});

Returned errors are integrated into field state. If a returned error includes fieldTree, it appears on that target field; otherwise, it belongs to the field being submitted. A submission error clears automatically when its target field changes; it does not recompute like a schema validator. Concurrent submissions are guarded: if a submission is already running for a field or one of its parents, another call returns false immediately.


Declarative submission with FormRoot

The declarative alternative to manual submission is FormRoot. It binds a form element directly to a field tree and uses the submission options from the form() configuration. FormRoot then handles the submission process.

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

Import FormRoot in the standalone component that owns this template, just as FormField is imported for [formField].

import {
  apply,
  form,
  FormField,
  FormRoot,
  max,
  metadata,
  min,
  required,
  validateStandardSchema,
} from "@angular/forms/signals";

advanced-desk-demo.ts

@Component({
  selector: "app-advanced-desk-demo",
  imports: [
    ChfInput,
    CowbellToggle,
    Errors,
    FormField,
    FormRoot,
    JsonPipe,
    SummitRating,
  ],
  templateUrl: "./advanced-desk-demo.html",
})

advanced-desk-demo.ts

The final argument to form() configures submission behavior. Its submission key can define the action, onInvalid, and ignoreValidators options. These options behave identically when passed directly to submit().

  readonly deskForm = form(
    this.deskModel,
    (path) => {
      apply(path.lead, travelerNameSchema);
      validateStandardSchema(path, swissPassSchema);
      min(path.souvenirBudget, 20, {
        message: "The souvenir budget must be at least CHF 20.",
      });
      max(path.souvenirBudget, 500, {
        message: "The souvenir budget must not exceed CHF 500.",
      });
      min(path.satisfaction, 4, {
        message: "Select a rating of 4 or 5.",
      });
      required(path.cowbellInsurance, {
        message: "Accept the cowbell insurance option.",
      });
      metadata(
        path.souvenirBudget,
        FIELD_HELP,
        () => "Custom metadata: recommended range of CHF 20–500.",
      );
      metadata(
        path.satisfaction,
        FIELD_HELP,
        () => "Custom metadata: this rating uses a FormValueControl.",
      );
    },
    {
      name: "advanced-desk",
      submission: {
        action: async () => {
          await new Promise((resolve) => setTimeout(resolve, 250));
          this.deskSubmitted.set(this.deskModel());
          this.showDeskErrors.set(false);
          return undefined;
        },
        onInvalid: () => {
          this.showDeskErrors.set(true);
          this.focusFirstDeskError();
        },
        ignoreValidators: "none",
      },
    },
  );

advanced-desk-demo.ts

Manual submission requires setting novalidate on the form, preventing the default submit event, and calling submit(). The declarative FormRoot handles all three tasks: it sets novalidate, prevents the browser's default submission, applies Signal Forms submission rules, marks interactive fields as touched, and runs the action only when validation allows it.


Resetting a form

Calling reset() without an argument clears the touched and dirty state but leaves the current model value unchanged. To restore defaults as well, pass the default model to reset().

A common pattern is to define a factory function that returns a fresh default model. This ensures that nested objects are also reset to new instances, avoiding shared references. Use this factory function when initializing the model signal and when resetting the form.

function createDefaultDesk(): AdvancedDeskModel {
  return {
    lead: {
      firstName: '',
      lastName: '',
    },
    swissPass: 'CH-1234',
    souvenirBudget: 120,
    satisfaction: 3,
    cowbellInsurance: false,
  };
}

readonly deskModel = signal<AdvancedDeskModel>(createDefaultDesk());
resetDesk(): void {
  this.deskForm().reset(createDefaultDesk());
}

Passing the fresh default model to reset() replaces the model value and clears the touched and dirty state of the form and its descendants.


Error summary

Each field state exposes errorSummary(), which includes errors for the field and descendants. This differs from errors(), which only returns errors attached directly to one field. This can be useful for rendering a summary of all errors in a form.

This example shows a summary of all form errors after submission. The showDeskErrors() signal is set to true when submission is attempted on an invalid form. The call to deskForm().errorSummary() returns an array of error objects, which are rendered in a list.

  @if (showDeskErrors() && deskForm().errorSummary().length) {
  <div class="summary" role="alert">
    <strong>Validation errors</strong>
    <ul>
      @for (error of deskForm().errorSummary(); track $index) {
      <li>{{ error.message || error.kind }}</li>
      }
    </ul>
    <button class="secondary" type="button" (click)="focusFirstDeskError()">
      Focus first problem
    </button>
  </div>
  }

advanced-desk-demo.html

Custom form controls

When a native HTML control cannot express the required interaction, a custom control can provide it. Integration with Signal Forms requires a contract that lets the custom control work with [formField] like a native control. FormValueControl<T> defines the contract for value controls, while FormCheckboxControl defines it for boolean controls.

Value controls

FormValueControl<T> defines the contract for a value control. The value is the only required property. It must be a model signal. Use model.required<T>() when the control always has a value, or model<T>() when undefined is part of the control's value type. This example implements a rating control that stores a number from 1 to 5.

@Component({
  selector: "app-summit-rating",
  templateUrl: "./summit-rating.html",
})
export class SummitRating implements FormValueControl<number> {
  readonly value = model.required<number>();
  readonly disabled = input(false);
  readonly invalid = input(false);
  readonly touched = input(false);
  readonly touch = output<void>();
  readonly scores = [1, 2, 3, 4, 5];
  private readonly firstButton =
    viewChild.required<ElementRef<HTMLButtonElement>>("ratingButton");

  focus(options?: FocusOptions): void {
    this.firstButton().nativeElement.focus(options);
  }

  onFocusOut(event: FocusEvent): void {
    const container = event.currentTarget as HTMLElement;
    const nextTarget = event.relatedTarget as Node | null;
    if (!nextTarget || !container.contains(nextTarget)) {
      this.touch.emit();
    }
  }
}

custom-controls.ts

The control consists of five buttons representing the rating scores. The template uses @for to iterate over the scores and bind each button to the value signal. The disabled, invalid, and touched states pass through from the field tree. The focus() method focuses the first button, and onFocusOut() emits touch when focus leaves the whole button group. Emitting the touch output when the interaction finishes marks the field as touched.

<div
  class="rating-control"
  role="group"
  aria-label="Summit desk rating"
  [class.is-invalid]="invalid() && touched()"
  (focusout)="onFocusOut($event)"
>
  @for (score of scores; track score) {
  <button
    #ratingButton
    class="secondary"
    type="button"
    [class.active]="value() >= score"
    [disabled]="disabled()"
    [attr.aria-label]="'Rate ' + score + ' out of 5'"
    [attr.aria-pressed]="value() === score"
    (click)="value.set(score)"
  >
    {{ score }}
  </button>
  }
</div>

summit-rating.html

Custom controls that follow the FormValueControl<T> contract can be bound to a field with [formField] in the same way as native controls.

          <app-summit-rating
            [formField]="deskForm.satisfaction"
          ></app-summit-rating>

advanced-desk-demo.html


Checkbox controls

Custom controls that represent a boolean value implement FormCheckboxControl. The contract is similar to FormValueControl<boolean>, but it does not have a value signal. Instead, it has a checked signal that represents the boolean state of the control. Because FormCheckboxControl.checked is boolean, create it with model.required<boolean>() or initialize it with model(false).

@Component({
  selector: "app-cowbell-toggle",
  templateUrl: "./cowbell-toggle.html",
})
export class CowbellToggle implements FormCheckboxControl {
  readonly checked = model.required<boolean>();
  readonly disabled = input(false);
  readonly touch = output<void>();
  private readonly button =
    viewChild.required<ElementRef<HTMLButtonElement>>("toggleButton");

  focus(options?: FocusOptions): void {
    this.button().nativeElement.focus(options);
  }
}

custom-controls.ts

This custom control is a toggle button that represents a boolean value. The template uses the checked signal to determine the button's state and updates it when the button is clicked. The focus() method focuses the button, and touch is emitted on blur.

<button
  #toggleButton
  class="secondary toggle"
  type="button"
  aria-label="Cowbell insurance"
  [class.active]="checked()"
  [disabled]="disabled()"
  [attr.aria-pressed]="checked()"
  (click)="checked.set(!checked())"
  (blur)="touch.emit()"
>
  {{ checked() ? "Covered" : "Uncovered" }}
</button>

cowbell-toggle.html

Like the value control, the checkbox control can be bound to a field with [formField]:

          <app-cowbell-toggle
            [formField]="deskForm.cowbellInsurance"
          ></app-cowbell-toggle>

advanced-desk-demo.html


UI state and focus

FormUiControl is the shared base contract for optional UI state such as errors, disabled, invalid, touched, constraints, focus(), and reset(). FormValueControl and FormCheckboxControl both extend this contract.

Emit the touch output on blur, not on focus. That keeps touched() aligned with native controls and lets debounce(path, 'blur') work correctly.

Implement focus() with an instance-local view query when the component host is not the interactive element:

  private readonly inputElement =
    viewChild.required<ElementRef<HTMLInputElement>>("inputElement");

  protected readonly displayValue = linkedSignal(() =>
    this.format(this.value()),
  );

  focus(options?: FocusOptions): void {
    this.inputElement().nativeElement.focus(options);
  }

custom-controls.ts

The template marks the input with #inputElement. The rating and toggle components also implement focus() for their internal buttons. The rating emits touch only when focus leaves the whole button group, not when focus moves between scores. This makes focusBoundControl() work correctly and gives keyboard users behavior consistent with native controls.


Validation with custom controls

Custom controls do not need a separate validation system. The field tree still owns validation, so schemas work the same way:

      min(path.satisfaction, 4, {
        message: "Select a rating of 4 or 5.",
      });
      required(path.cowbellInsurance, {
        message: "Accept the cowbell insurance option.",
      });

advanced-desk-demo.ts

The control receives state such as invalid, touched, and disabled from [formField], while the schema remains near the model definition. This keeps custom controls reusable: the rating component does not need to know whether one screen requires a score of 4 and another accepts a score of 2.


Value transformations with linkedSignal()

A custom control that displays one representation but stores another can use linkedSignal() to keep the display synchronized with the model. The app-chf-input control displays a string such as "120" while storing the number 120 in the form model. The displayValue signal is linked to the value signal and formats the number for display. On blur, the updateModel() method parses the display value and updates the model when the result is valid.

export class ChfInput implements FormValueControl<number> {
  readonly value = model.required<number>();
  readonly disabled = input(false);
  readonly invalid = input(false);
  readonly touched = input(false);
  readonly touch = output<void>();
  private readonly inputElement =
    viewChild.required<ElementRef<HTMLInputElement>>("inputElement");

  protected readonly displayValue = linkedSignal(() =>
    this.format(this.value()),
  );

  focus(options?: FocusOptions): void {
    this.inputElement().nativeElement.focus(options);
  }

  reset(): void {
    this.displayValue.set(this.format(this.value()));
  }

  updateModel(): void {
    const normalized = this.displayValue()
      .replace(/['’\s]/g, "")
      .trim();
    const parsed = Number(normalized);
    if (Number.isFinite(parsed)) {
      this.value.set(parsed);
      this.displayValue.set(this.format(parsed));
    } else {
      this.displayValue.set(this.format(this.value()));
    }
  }

  private format(value: number): string {
    return value.toLocaleString("de-CH", { maximumFractionDigits: 2 });
  }
}

custom-controls.ts

<div class="money-control">
  <span>CHF</span>
  <input
    #inputElement
    aria-label="Souvenir budget in Swiss francs"
    [value]="displayValue()"
    [disabled]="disabled()"
    [class.is-invalid]="invalid() && touched()"
    (input)="displayValue.set($any($event.target).value)"
    (blur)="updateModel(); touch.emit()"
  />
</div>

chf-input.html

The linked signal resets when the bound model changes from outside the control. On blur, the example accepts text that parses to a finite number and normalizes the display. Invalid text is replaced with the formatted current model value instead of leaving stale text that could disagree with the submitted number.

Use this pattern when the model stores a value separately from its formatted UI representation. Currency, percentages, phone numbers, dates, and comma-separated tags are examples. Store 120, rather than "CHF 120.00", when the domain value is numeric.

transformedValue() exposes invalid raw input through parseErrors() and, when used inside a field context, automatically reports those errors to the nearest field. The CHF demo restores the display value on blur.

Additional form patterns

This section covers additional patterns common in real-world applications, including dynamic forms, focus management, and status CSS classes.


Dynamic forms

Dynamic forms allow fields to be added or removed at runtime. Examples are forms where the user can enter multiple email addresses, phone numbers, or shopping-cart items. The Signal Forms API supports dynamic forms through arrays, including arrays of objects. In this example, the model signal contains an array of objects, and the field tree mirrors that array.

The following model has a title and an items array. Each item contains a name and a quantity.

  readonly picnicModel = signal<PicnicModel>({
    title: "Emergency fondue logistics",
    items: [
      { name: "Gruyere", quantity: 2 },
      { name: "Bread cube", quantity: 24 },
    ],
  });

picnic-demo.ts

The schema validates the array itself with minLength(path.items, 1), so the picnic must contain at least one item. applyEach() then applies the same required() and min() rules to every item in the array. These rules also apply to items added later. So in this example, the user must enter a name and a quantity of at least 1 for every item in the picnic.

  readonly picnicForm = form(this.picnicModel, (path) => {
    required(path.title, {
      message: "Enter a picnic title.",
    });
    minLength(path.items, 1, {
      message: "Add at least one picnic item.",
    });
    applyEach(path.items, (item) => {
      required(item.name, {
        message: "Enter an item name.",
      });
      required(item.quantity, {
        message: "Enter a quantity.",
      });
      min(item.quantity, 1, {
        message: "Quantity must be at least 1.",
      });
    });
  });

picnic-demo.ts

In the template, @for iterates over picnicForm.items. Each generated item exposes its own name and quantity fields for [formField]. Tracking by item keeps the existing field instances associated with their rows when the array changes. The same template displays errors for the individual controls and for the array-level minLength() rule.

  <div class="items">
    @for (item of picnicForm.items; track item; let i = $index) {
    <div class="item-row">
      <label>
        Item
        <input [formField]="item.name" />
      </label>
      <label>
        Qty
        <input type="number" [formField]="item.quantity" />
      </label>
      <button class="secondary" type="button" (click)="removePicnicItem(i)">
        Remove
      </button>
    </div>
    <app-errors [field]="item.name"></app-errors>
    <app-errors [field]="item.quantity"></app-errors>
    }
  </div>
  <app-errors [field]="picnicForm.items"></app-errors>

  <div class="actions">
    <button type="button" (click)="addPicnicItem()">Add item</button>
    <span class="pill">
      Total things packed: {{ picnicTotal() | number: "1.0-0" }}
    </span>
  </div>

picnic-demo.html

The add and remove buttons update picnicModel immutably. Signal Forms synchronizes the field tree with the new array, and the picnicTotal computed signal recalculates the total quantity. Because programmatic model updates do not automatically count as user edits, the handlers explicitly mark the items field as dirty. Removing an item also marks the array as touched so the "at least one item" error can be displayed when the final row is removed.

  readonly picnicTotal = computed(() =>
    this.picnicModel().items.reduce(
      (sum, item) => sum + Number(item.quantity || 0),
      0,
    ),
  );

  addPicnicItem(): void {
    this.picnicModel.update((model) => ({
      ...model,
      items: [
        ...model.items,
        {
          name: "Chocolate square",
          quantity: 4,
        },
      ],
    }));
    this.picnicForm.items().markAsDirty();
  }

  removePicnicItem(index: number): void {
    this.picnicModel.update((model) => ({
      ...model,
      items: model.items.filter((_, itemIndex) => itemIndex !== index),
    }));
    this.picnicForm.items().markAsDirty();
    this.picnicForm.items().markAsTouched({ skipDescendants: true });

picnic-demo.ts


Focus management

A common requirement is to focus the first field when opening a form or the first invalid field after submission. The Signal Forms API provides focusBoundControl() for this purpose. It works with native controls and custom controls that implement focus().

In this example, the focusFirstDeskError() method focuses the first invalid field after submission. It retrieves the error list with errorSummary() and then calls focusBoundControl() on the first error's field tree.

  focusFirstDeskError(): void {
    this.deskForm()
      .errorSummary()[0]
      ?.fieldTree()
      .focusBoundControl({ preventScroll: false });
  }

advanced-desk-demo.ts


If a form should focus its first control when it opens, wait until Angular has rendered the bound input, then focus the first field:

import { afterNextRender } from '@angular/core';

constructor() {
  afterNextRender(() => {
    this.deskForm.lead.firstName().focusBoundControl();
  });
}

For native controls, focusBoundControl() can focus the input automatically. For custom controls, implement focus() when the host element itself is not the right thing to focus. This keeps custom widgets accessible through the same form-level workflow as native inputs.


Status CSS classes

Reactive Forms and template-driven forms add CSS status classes such as ng-valid, ng-invalid, ng-touched, ng-untouched, ng-dirty, ng-pristine, and ng-pending to bound controls. Signal Forms does not add those classes by default. If you need them for styling or for compatibility with existing CSS, you can add them at the application or environment-provider level through the classes configuration option in provideSignalFormsConfig(). The built-in class names are defined in NG_STATUS_CLASSES.

import { provideHttpClient } from "@angular/common/http";
import { provideSignalFormsConfig } from "@angular/forms/signals";
import { NG_STATUS_CLASSES } from "@angular/forms/signals/compat";
import { bootstrapApplication } from "@angular/platform-browser";
import { App } from "./app/app";

bootstrapApplication(App, {
  providers: [
    provideHttpClient(),
    provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }),
  ],
}).catch((error: unknown) => console.error(error));

main.ts


If you don't want to use the built-in class names, you can provide your own. The classes object maps class names to callbacks that receive a field context and return a boolean indicating whether each class should be applied. The field context provides state signals such as invalid(), touched(), pending(), dirty(), and disabled().

provideSignalFormsConfig({
  classes: {
    'sf-invalid-after-touch': ({ state }) =>
      state().invalid() && state().touched(),
    'sf-pending': ({ state }) => state().pending(),
    'sf-dirty': ({ state }) => state().dirty(),
    'sf-disabled': ({ state }) => state().disabled(),
  },
});

Field metadata

Metadata attaches reactive UI or descriptive information to a field without adding it to the submitted model. Examples include help text, grouping information, analytics labels, and other UI configuration. The metadata can be read through the field state.

First, create a metadata key with createMetadataKey(). Then attach the metadata to a field with metadata(). The metadata can be any value, such as a string, number, object, or function.

import { createMetadataKey, required, schema } from "@angular/forms/signals";
import type { AdvancedDeskModel } from "./models";

export const FIELD_HELP = createMetadataKey<string>();

advanced-schemas.ts

      metadata(
        path.souvenirBudget,
        FIELD_HELP,
        () => "Custom metadata: recommended range of CHF 20–500.",
      );

advanced-desk-demo.ts

The template accesses metadata through the field state:

<span class="help-text">
  {{ deskForm.souvenirBudget().metadata(FIELD_HELP)?.() }}
</span>

Derived state with computed() and effect()

Forms often need derived state that is not submitted, such as a password-strength indicator, a total price, or an "all required fields are filled" indicator. The following example calculates password strength with computed():

  readonly passwordStrength = computed(() => {
    const password = this.permitForm.profile.password().value();
    const score =
      Number(password.length >= 8) +
      Number(/[A-Z]/.test(password)) +
      Number(/\d/.test(password));
    return ["weak", "steady", "summit-ready"][Math.min(score, 2)];
  });

permit-demo.ts

Use computed() for derived UI state that is not submitted. Password strength, item totals, and "all required fields are filled" indicators are examples.

Use effect() for side effects such as updating autosave indicators, logging, sending analytics events, or synchronizing with external storage. Writing to the same field that an effect reads can create a loop.

This example uses effect() to update the draftSavedAt signal whenever the form model changes.

    effect(() => {
      this.permitModel();
      this.draftSavedAt.set(new Date().toLocaleTimeString());
    });

permit-demo.ts

Testing

Isolated form testing

Signal Forms tests can use the same test runner and assertion library as other Angular code.

This example uses Vitest to test a simple form with a required field. The test creates a model signal, a form with a required rule, and checks the validity of the field before and after setting a value. Because form() requires an injection context, the test passes an Injector from TestBed through the form options.

import { Injector, signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { FormControl } from "@angular/forms";
import {
  form,
  maxDate,
  minDate,
  pattern,
  required,
} from "@angular/forms/signals";
import { compatForm, extractValue } from "@angular/forms/signals/compat";
import { describe, expect, it } from "vitest";

describe("Signal Forms", () => {
  it("validates a required field", () => {
    const model = signal({ name: "" });
    const testForm = form(
      model,
      (path) => {
        required(path.name, { message: "Enter a name." });
      },
      { injector: TestBed.inject(Injector) },
    );

    expect(testForm.name().getError("required")).toBeDefined();

    testForm.name().value.set("Ada Alpine");

    expect(testForm.name().valid()).toBe(true);
  });

signal-form.spec.ts

Common pitfalls

The following details are common sources of errors:

Wrapping up

The Signal Forms API provides a simple and consistent way to manage form state, validation, and submission in Angular. Applications that already use signals are a natural fit for implementing forms with Signal Forms. The API supports native and custom controls, includes common validation patterns, and remains extensible for custom requirements.

Signal Forms does not replace the two existing form APIs. Reactive Forms and template-driven forms remain supported and can coexist with Signal Forms in the same application. Check out the documentation about interoperability and migration for more information.

Further reading

The following resources provide more information about Signal Forms: