# Signal Forms Signal Forms are the recommended approach for handling forms in modern Angular applications (v21+). They provide a reactive, type-safe, and model-driven way to manage form state using Angular Signals. **CRITICAL**: You MUST use Angular's new Signal Forms API for all form-related functionality. Do NOT use null as a value or type of any fields. ## Imports You can import the following from `@angular/forms/signals`: ```ts import { form, FormField, submit, // Rules for field state disabled, hidden, readonly, debounce, // Schema helpers applyWhen, applyEach, schema, // Custom validation validate, validateHttp, validateStandardSchema, // Metadata metadata, } from '@angular/forms/signals'; ``` ## Creating a Form Use the `form()` function with a Signal model. The structure of the form is derived directly from the model. ```ts import {Component, signal} from '@angular/core'; import {form, FormField} from '@angular/forms/signals'; @Component({ // ... imports: [FormField], }) export class Example { // 1. Define your model with initial values (avoid undefined) userModel = signal({ name: '', // CRITICAL: NEVER use null or undefined as initial values email: '', age: 0, // Use 0 for numbers, NOT null address: { street: '', city: '', }, hobbies: [] as string[], // Use [] for arrays, NOT null }); // WRONG - DO NOT DO THIS: // badModel = signal({ // name: null, // ERROR: use '' instead // age: null, // ERROR: use 0 instead // items: null // ERROR: use [] instead // }); // 2. Create the form userForm = form(this.userModel); } ``` ## Validation Import validators from `@angular/forms/signals`. ```ts import {required, email, min, max, minLength, maxLength, pattern} from '@angular/forms/signals'; ``` Use them in the schema function passed to `form()`: ```ts userForm = form(this.userModel, (schemaPath) => { // Required required(schemaPath.name, {message: 'Name is required'}); // Conditional required. required(schemaPath.name, { when({valueOf}) { return valueOf(schemaPath.age) > 10; }, }); // when is only available for required // Do NOT do this: pattern(p.name, /xxx/, {when /* ERROR */) // Email email(schemaPath.email, {message: 'Invalid email'}); // Min/Max for numbers min(schemaPath.age, 18); max(schemaPath.age, 100); // MinLength/MaxLength for strings/arrays minLength(schemaPath.password, 8); maxLength(schemaPath.description, 500); // Pattern (Regex) pattern(schemaPath.zipCode, /^\d{5}$/); }); ``` ## FieldState vs FormField: The Parental Requirement It's important to understand the difference between **FormField** (the structure) and **FieldState** (the actual data/signals). **RULE**: You must **CALL** a field as a function to access its state signals (valid, touched, dirty, hidden, etc.). ```ts // f is a FormField (structural) const f = form(signal({cat: {name: 'pirojok-the-cat', age: 5}})); f.cat.name; // FormField: You can't get flags from here! f.cat.name.touched(); // ERROR: touched() does not exist on FormField f.cat.name(); // FieldState: Calling it gives you access to signals f.cat.name().touched(); // VALID: Accessing the signal f.cat().name.touched(); // ERROR: f.cat() is state, it doesn't have children! ``` Similarly in a template: ```html @if (bookingForm.hotelDetails.hidden()) { ... } @if (bookingForm.hotelDetails().hidden()) { ... } ``` ## Disabled / Readonly / Hidden Control field status using rules in the schema. ```ts import {disabled, readonly, hidden} from '@angular/forms/signals'; userForm = form(this.userModel, (schemaPath) => { // Conditionally disabled disabled(schemaPath.password, ({valueOf}) => !valueOf(schemaPath.createAccount)); // Conditionally hidden (does NOT remove from model, just marks as hidden) hidden(schemaPath.shippingAddress, ({valueOf}) => valueOf(schemaPath.sameAsBilling)); // Readonly readonly(schemaPath.username); }); ``` ## Binding Import `FormField` and use the `[formField]` directive. ```ts import {FormField} from '@angular/forms/signals'; ``` All props on state, such as `disabled`, `hidden`, `readonly` and `name` are bound automatically. Do _NOT_ bind the `name` field. **CRITICAL: FORBIDDEN ATTRIBUTES** When using `[formField]`, you MUST NOT set the following attributes in the template (either static or bound): - `min`, `max` (Use validators in the schema instead) - `value`, `[value]`, `[attr.value]` (Already handled by `[formField]`) - `[attr.min]`, `[attr.max]` - `[disabled]`, `[readonly]` (Already handled by `[formField]`) Do NOT do this: `` or ``. ```html ``` ## Reactive Forms **Do NOT import** `FormControl`, `FormGroup`, `FormArray`, or `FormBuilder` from `@angular/forms`. Signal Forms replace these concepts entirely. Signal forms does NOT have a builder. ## Accessing State Each field in the form is a function that returns its state. ```ts // Access the field by calling it const emailState = this.userForm.email(); // Value (WritableSignal) const value = this.userForm().value(); // Validation State (Signals) const isValid = this.userForm().valid(); const isInvalid = this.userForm().invalid(); const errors = this.userForm().errors(); // Array of errors const isPending = this.userForm().pending(); // Async validation pending // Interaction State (Signals) const isTouched = this.userForm().touched(); const isDirty = this.userForm().dirty(); // Availability State (Signals) const isDisabled = this.userForm().disabled(); const isHidden = this.userForm().hidden(); const isReadonly = this.userForm().readonly(); ``` IMPORTANT!: Make sure to call the field to get it state. ```ts form().invalid() form.field().dirty() form.field.subfield().touched() form.a.b.c.d().value() form.address.ssn().pending() form().reset() // The only exception is length: form.children.length form.length // NOTE: no parenthesis! form.client.addresses.length // No "()" @for (income of form.addresses; track $index) {/**/} ``` ## Submitting Use the `submit()` function. It automatically marks all fields as touched before running the action. **CRITICAL**: The callback to `submit()` MUST be `async` and MUST return a Promise. ```ts import { submit } from '@angular/forms/signals'; // CORRECT - async callback onSubmit() { submit(this.userForm, async () => { // This only runs if the form is valid await this.apiService.save(this.userModel()); console.log('Saved!'); }); } // WRONG - missing async keyword onSubmit() { submit(this.userForm, () => { // ERROR: must be async console.log('Saved!'); }); } ``` ## Handling Errors `field().errors()` returns the errors array of ValidationError: ```ts interface ValidationError { readonly kind: string; readonly message?: string; } ``` Do _NOT_ return null from validators. When there are no errors, return undefined ### Context Functions passed to rules like `validate()`, `disabled()`, `applyWhen` take a context object. It is **CRITICAL** to understand its structure: ```ts validate( schemaPath.username, ({ value, // Signal: Writable current value of the field fieldTree, // FieldTree: Sub-fields (if it's a group/array) state, // FieldState: Access flags like state.valid(), state.dirty() valueOf, // (path) => T: Read values of OTHER fields (tracking dependencies), e.g. valueOf(schemaPath.password) stateOf, // (path) => FieldState: Access state (valid/dirty) of OTHER fields, e.g. stateOf(schemaPath.password).valid() pathKeys, // Signal: Path from root to this field }) => { // WRONG: if (touched()) ... (touched is not in context) // RIGHT: if (state.touched()) ... if (value() === 'admin') { return {kind: 'reserved', message: 'Username admin is reserved'}; } }, ); ``` ### IMPORTANT: Paths are NOT Signals Inside the `form()` callback, `schemaPath` and its children (e.g., `schemaPath.user.name`) are **NOT** signals and are **NOT** callable. ```ts // WRONG - This will throw an error: applyWhen(p.ssn, () => p.ssn().touched(), (ssnField) => { ... }); // RIGHT - Use stateOf() to get the state of a path: applyWhen(p.ssn, ({ stateOf }) => stateOf(p.ssn).touched(), (ssnField) => { ... }); // RIGHT - Use valueOf() to get the value of a path: applyWhen(p.ssn, ({ valueOf }) => valueOf(p.ssn) !== '', (ssnField) => { ... }); ``` ### Multiple Items - Use `applyEach` for applying rules per item. - **CRITICAL**: `applyEach` callback takes ONLY ONE argument (the item path), NOT two: ```ts // CORRECT - single argument applyEach(s.items, (item) => { required(item.name); }); // WRONG - do NOT pass index applyEach(s.items, (item, index) => { // ERROR: callback takes 1 argument required(item.name); }); ``` - In the template use `@for` to iterate over the items. - To remove an item from an array, just remove appropriate item from the array in the data. - **`select` binding**: You CAN bind to `` (string[]) | Use checkboxes for array fields | | **readonly attribute** | `` | Use `readonly()` rule in schema | | **min/max attributes** | `` | Use `min()` and `max()` rules in schema | | **value binding** | `` | Do NOT use `[value]` with `[formField]` | | **when option** | `pattern(p.x, /.../, {when: ...})` | `when` only works with `required()` | | **Submit callback** | `submit(form, () => { ... })` | `submit(form, async () => { ... })` | | **Async params** | `params: s.field` | `params: ({ value }) => value()` | | **Async onError** | Omitting `onError` | `onError` is REQUIRED in `validateAsync` | | **resource() API** | `request: signal` | `params: signal` | | **applyEach args** | `applyEach(s.items, (item, index) => ...)` | `applyEach(s.items, (item) => ...)` | | **Nested @for** | `$parent.$index` | Use `let outerIndex = $index` | | **FormState import** | `import { FormState }` | `FormState` does not exist, use `FieldState` | | **Null in model** | `signal({ name: null })` | `signal({ name: '' })` or `signal({ age: 0 })` | | **Validate syntax** | `validate(s.field, { value } => ...)` | `validate(s.field, ({ value }) => ...)` | | **Checkbox Array** | `[formField]="form.tags"` (string[]) | Checkboxes ONLY bind to `boolean` | ## Big Form Example ### `src/app/app.ts` ```ts import {Component, signal, ChangeDetectionStrategy} from '@angular/core'; import { form, FormField, submit, required, email, min, hidden, applyEach, validate, } from '@angular/forms/signals'; @Component({ selector: 'app-root', standalone: true, imports: [FormField], templateUrl: './app.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { model = signal({ personalInfo: { firstName: '', lastName: '', email: '', age: 0, }, tripDetails: { destination: 'Mars', launchDate: '', }, package: { tier: 'economy', extras: [] as string[], }, companions: [] as Array<{name: string; relation: string}>, }); bookingForm = form(this.model, (s) => { required(s.personalInfo.firstName, {message: 'First name is required'}); required(s.personalInfo.lastName, {message: 'Last name is required'}); required(s.personalInfo.email, {message: 'Email is required'}); email(s.personalInfo.email, {message: 'Invalid email address'}); required(s.personalInfo.age, {message: 'Age is required'}); min(s.personalInfo.age, 18, {message: 'Must be at least 18'}); required(s.tripDetails.destination); required(s.tripDetails.launchDate); validate(s.tripDetails.launchDate, ({value}) => { const date = new Date(value()); if (isNaN(date.getTime())) return undefined; const today = new Date(); if (date < today) { return {kind: 'pastData', message: 'Launch date must be in the future'}; } return undefined; }); // valueOf is used to access values of other fields in rules hidden(s.package.extras, ({valueOf}) => valueOf(s.package.tier) === 'economy'); applyEach(s.companions, (companion) => { required(companion.name, {message: 'Companion name required'}); required(companion.relation, {message: 'Relation required'}); }); }); addCompanion() { this.model.update((m) => ({ ...m, companions: [...m.companions, {name: '', relation: ''}], })); } removeCompanion(index: number) { this.model.update((m) => ({ ...m, companions: m.companions.filter((_, i) => i !== index), })); } onSubmit() { // CRITICAL: submit callback MUST be async submit(this.bookingForm, async () => { console.log('Booking Confirmed:', this.model()); // If you need to do async work: // await this.apiService.save(this.model()); }); } } ``` ### `src/app/app.html` ```html

Interstellar Booking

Personal Info

Trip Details

Package

@if (!bookingForm.package.extras().hidden()) {

Extras

}

Companions

@for (companion of bookingForm.companions; track $index) {
@if (companion.name().touched() && companion.name().errors().length) { {{ companion.name().errors()[0].message }} } @if (companion.relation().touched() && companion.relation().errors().length) { {{ companion.relation().errors()[0].message }} }
}
``` ## Recovering from Build Errors If you encounter build errors, here are the most common fixes: ### `Property 'value' does not exist on type 'FieldTree'` **Problem**: Accessing `.value()` directly on a field without calling it first. ```ts // WRONG const val = this.form.field.value(); // RIGHT const val = this.form.field().value(); ``` ### `Property 'set' does not exist on type 'FieldTree'` **Problem**: Trying to set values on the form tree. Signal Forms are model-driven. ```ts // WRONG this.form.address.street.set('Main St'); // RIGHT - update the model signal instead this.model.update((m) => ({...m, address: {...m.address, street: 'Main St'}})); ``` ### `Type 'string[]' is not assignable to type 'string'` **Problem**: Binding `[formField]` to an array field with a single-value ` ... ``` ### `NG8022: Setting the 'readonly/min/max/value' attribute is not allowed` **Problem**: Conflict between HTML attributes and `[formField]` directive. ```html min(s.age, 18); max(s.age, 99); // Then just: ``` ### `TS2322: Type 'string[]' is not assignable to type 'boolean'` **Problem**: Binding a checkbox to an array field instead of a boolean field. ```html model = signal({ hasWifi: false, hasGym: false }); ``` ### `'when' does not exist in type` for pattern/email/min/max **Problem**: Using `when` option with validators other than `required`. ```ts // WRONG - when only works with required pattern(s.ssn, /^\d{3}-\d{2}-\d{4}$/, {when: isJoint}); // RIGHT - use applyWhen for conditional non-required validators applyWhen(s.ssn, isJoint, (ssnPath) => { pattern(ssnPath, /^\d{3}-\d{2}-\d{4}$/); }); ``` ### `Expected 3 arguments, but got 2` for applyWhen **Problem**: Missing the path argument in `applyWhen`. ```ts // WRONG applyWhen(isJoint, () => { ... }); // RIGHT - applyWhen(path, condition, schemaFn) applyWhen(s.spouse, ({valueOf}) => valueOf(s.status) === 'joint', (spousePath) => { required(spousePath.name); }); ``` ### `Module has no exported member 'FormState'` **Problem**: Importing a non-existent type. ```ts // WRONG import {FormState} from '@angular/forms/signals'; // FormState does not exist. If you need type access, the form // instance provides all necessary state through field().valid(), etc. ``` ### `No pipe found with name 'number'` / `'json'` / `'date'` **Problem**: Using pipes in templates. ```html {{ totalPrice() | number:'1.2-2' }} totalPriceFormatted = computed(() => this.totalPrice().toFixed(2)); {{ totalPriceFormatted() }} ``` ### `$parent.$index` in nested @for loops **Problem**: Angular doesn't have `$parent`. ```html @for (item of items; track $index) { @for (sub of item.subs; track $index) { } } @for (item of items; track $index; let outerIdx = $index) { @for (sub of item.subs; track $index) { } } ```