Core Concepts

Core Concepts

Data Model

One of the most important concepts in the Angular Signal Forms is the Data Model. Everything starts from there. When we create a Signal Form, it uses the Data Model as the source of truth for the values of the form fields. There is no internal copy of the data maintained from the Signals Forms library. That means that updating the Data Model will update automatically a form field's value. In the same way, changing the value of form field, will directly modify the Data Model.

Also, when creating a Signal Form, the resulting form structure (FieldTree) will match the shape of the Data Model.

Create form

If the form() factory is not defined inside an injection context (e.g contructor), an injector can be passed in the form options object. form(signal({username: ''}, {injector: this.injector}));.

Binding form fields to UI elements

After defining the data model and generating the form field tree, the next step is to bind each field to its corresponding UI element. Angular provides the Field directive to accomplish this.

A UI element can be one of the three:

UI elementDescription
Native HTML form elementA standard browser form control
Signal forms custom controlA custom form control built using Angular signal-based forms
ControlValueAccessor componentA component implementing CVA for Angular Reactive/Template-driven forms

This directive will:

  • Create a two way binding between the native UI element's value and the form field's value.
  • Synchronize the field state (disabled, touched, required, etc.) with the native UI element.
Address

Form Inspector

Field Tree

(root) valid: true | invalid: false | pending: false
value: {
  "firstName": "Stef",
  "lastName": "Modify me",
  "address": {
    "street": "123 Main St",
    "city": "Anytown"
  }
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

firstName valid: true | invalid: false | pending: false
value: "Stef"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

lastName valid: true | invalid: false | pending: false
value: "Modify me"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

address valid: true | invalid: false | pending: false
value: {
  "street": "123 Main St",
  "city": "Anytown"
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

street valid: true | invalid: false | pending: false
value: "123 Main St"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

city valid: true | invalid: false | pending: false
value: "Anytown"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { Field, form } from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';

interface MyDataModel {
  firstName: string;
  lastName: string;
  address: {
    street: string;
    city: string;
  };
}

@Component({
  selector: 'field-tree',
  templateUrl: './field-tree.html',
  imports: [Field, FormInspectorComponent, DemoLayout],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FieldTree {
  protected readonly myForm = form(
    signal<MyDataModel>({
      firstName: 'Stef',
      lastName: 'Modify me',
      address: { street: '123 Main St', city: 'Anytown' },
    }),
  );
}

Field Logic

Now that every field node is synchronized with the corresponding UI element using the [field] directive, we can add logic to it. We can add the follwing types of logic in our form fields.

Type of logicDescription
hide fieldsLogic that determines if the field is hidden.
disable fieldsLogic that determines reasons for the field being disabled.
readonly fieldsLogic that determines if the field is read-only.
validation errorsLogic that produces synchronous validation errors for the field.
validation errors (field's subtree)Logic that produces synchronous validation errors for the field's subtree.
asynchronous validation errrosLogic that produces asynchronous validation results (errors or 'pending').

To add logic like this to our form we need to use a Schema. In the Schema we declaretively define the logic of our form. You can think the Schema as a function that accepts a FieldPath and defines the logic for it.

FieldPath<T> is an object that represents a location in the FieldTree form structure and is used to bind logic to a particular part of the structure prior to the creation of the form.

Create Schema

We explore the Field Logic topic in depth in the next page, with additional examples and detailed explanations.

Field State

Based on the Field Logic we have defined using the Schema, we get the Field State. To access the state associated with a field, call it as a function as you can see in the following demo:

Form Inspector

Field Tree

(root) valid: true | invalid: false | pending: false
value: {
  "firstName": "Delete me",
  "lastName": "Disabled"
}
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

firstName valid: true | invalid: false | pending: false
value: "Delete me"
touched: false
dirty: false
hidden: false
readonly: false
disabled: false
disabledReasons: (0)
[]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": true
}
            

Children

— none —

lastName valid: true | invalid: false | pending: false
value: "Disabled"
touched: false
dirty: false
hidden: false
readonly: false
disabled: true
disabledReasons: (1)
[
  {
    "message": "Last name cannot be changed"
  }
]
errors: (0)
[]
errorSummary: (0)
[]
meta: {
  "pattern": [],
  "required": false
}
            

Children

— none —

TypeScript
HTML
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { disabled, Field, form, required, schema } from '@angular/forms/signals';
import { FormInspectorComponent } from '../../../ui/form-inspector.ts/form-inspector';
import { DemoLayout } from '../../../ui/demo-layout/demo-layout';

interface MyDataModel {
  firstName: string;
  lastName: string;
}

@Component({
  selector: 'field-logic',
  templateUrl: './field-logic.html',
  imports: [Field, FormInspectorComponent, DemoLayout],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FieldLogic {
  protected readonly myForm = form(
    signal<MyDataModel>({
      firstName: 'Delete me',
      lastName: 'Disabled',
    }),
    schema<MyDataModel>((schemaPath) => {
      required(schemaPath.firstName);
      disabled(schemaPath.lastName, 'Last name cannot be changed');
    }),
  );
}

The Field State includes the following state which is derived from the Field Logic. Here is a reference of all the available Field states we can get:

Field StateDescription
errorsA signal containing the current errors for the field.
invalidA signal indicating whether the field is valid.
disabledA signal indicating whether the field is currently disabled.
disabledReasonsA signal containing the reasons why the field is currently disabled.
maxA signal indicating the field's maximum value, if applicable (numeric/date inputs & custom controls).
maxLengthA signal indicating the field's maximum string length, if applicable (<input>, <textarea>, custom controls).
minA signal indicating the field's minimum value, if applicable (numeric/date inputs & custom controls).
minLengthA signal indicating the field's minimum string length, if applicable (<input>, <textarea>, custom controls).
nameA signal containing the field's unique name, typically based on its parent field name.
patternA signal indicating patterns the field must match (array of RegExp).
readonlyA signal indicating whether the field is currently readonly.
requiredA signal indicating whether the field is required.
touchedA signal indicating whether the field has been touched by the user.
valueA writable signal containing the field’s value; updates sync with the bound data model.