adev/src/content/guide/forms/signals/form-logic.md
Signal Forms allow you to add logic to your form using schemas. Validation logic is covered in the Validation guide, and this guide discusses other rules available in schemas. You can disable fields conditionally, hide them based on other values, make them readonly, debounce user input, and attach metadata for custom controls.
This guide shows you how to use rules like disabled(), hidden(), readonly(), debounce(), and metadata() to control field behavior.
Use rules when field behavior depends on other field values or needs to update reactively. For example:
Rules bind reactive logic to specific fields in your form. Most conditional rules accept an options object with a when function. The when function automatically recomputes whenever the signals it references change, just like a computed.
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
//~~~~~~ ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//rule path reactive logic function
});
Reactive logic functions receive a FieldContext object that provides access to field values and state through helper functions like valueOf() and stateOf(). It is often destructured to access these helpers directly.
NOTE: The schema callback parameter (schemaPath in these examples) is a SchemaPathTree object that provides paths to all fields in your form. You can name this parameter anything you like.
For complete details on FieldContext properties and methods, see the Validation guide.
disabled()The disabled() rule configures a field's disabled state.
It works with the [formField] directive to automatically bind the disabled attribute based on the field's state, so you don't need to manually add [disabled]="yourForm.fieldName().disabled()" to your template.
NOTE: Disabled fields skip validation - they don't participate in form validation checks. The field's value is preserved but not validated. For details on validation behavior, see the Validation guide.
To disable a field permanently, call disabled() with just the field path:
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-settings',
imports: [FormField],
template: `
<label>
System ID (cannot be changed)
<input [formField]="settingsForm.systemId" />
</label>
`,
})
export class Settings {
settingsModel = signal({
systemId: 'SYS-12345',
userName: '',
});
settingsForm = form(this.settingsModel, (schemaPath) => {
disabled(schemaPath.systemId);
});
}
To disable a field based on conditions, provide a when function that returns true (disabled) or false (enabled):
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
Order Total
<input type="number" [formField]="orderForm.total" />
</label>
<label>
Coupon Code
<input [formField]="orderForm.couponCode" />
</label>
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {when: ({valueOf}) => valueOf(schemaPath.total) < 50});
});
}
In this example, when the order total is less than $50, the coupon code field is disabled.
When you disable a field, provide user-facing explanations by returning a string instead of true:
import {Component, signal} from '@angular/core';
import {form, FormField, disabled} from '@angular/forms/signals';
@Component({
selector: 'app-order',
imports: [FormField],
template: `
<label>
Order Total
<input type="number" [formField]="orderForm.total" />
</label>
<label>
Coupon Code
<input [formField]="orderForm.couponCode" />
</label>
@if (orderForm.couponCode().disabled()) {
<div class="info">
@for (reason of orderForm.couponCode().disabledReasons(); track reason) {
<p>{{ reason.message }}</p>
}
</div>
}
`,
})
export class Order {
orderModel = signal({
total: 25,
couponCode: '',
});
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, {
when: ({valueOf}) =>
valueOf(schemaPath.total) < 50 ? 'Order must be $50 or more to use a coupon' : false,
});
});
}
The when function returns:
false to enable the field (not just any falsy value - use false explicitly)Access the reasons through the disabledReasons() signal on the field state. Each reason has a message property containing the string you returned.
You can also call disabled() multiple times on the same field, and all of the returned reasons accumulate:
orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.promoCode, {
when: ({valueOf}) =>
!valueOf(schemaPath.hasAccount) ? 'You must have an account to use promo codes' : false,
});
disabled(schemaPath.promoCode, {
when: ({valueOf}) => (valueOf(schemaPath.total) < 25 ? 'Order must be at least $25' : false),
});
});
If both conditions are true, the field shows both disabled reasons. This pattern is useful for complex availability rules that you want to keep separate.
hidden() state on fieldsThe hidden() rule configures a field's hidden state. However, this only sets a programmatic state. You control whether the field appears in the UI.
IMPORTANT: Unlike disabled and readonly, there is no native DOM property for hidden state. The [formField] directive does not apply a hidden attribute to elements. You must use @if or CSS in your template to conditionally render fields based on the hidden() state.
NOTE: Like disabled fields, hidden fields also skip validation. See the Validation guide for details.
Use hidden() with a when function that returns true (hidden) or false (visible):
import {Component, signal} from '@angular/core';
import {form, FormField, hidden} from '@angular/forms/signals';
@Component({
selector: 'app-profile',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="profileForm.isPublic" />
Make profile public
</label>
@if (!profileForm.publicUrl().hidden()) {
<label>
Public URL
<input [formField]="profileForm.publicUrl" />
</label>
}
`,
})
export class Profile {
profileModel = signal({
isPublic: false,
publicUrl: '',
});
profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, {when: ({valueOf}) => !valueOf(schemaPath.isPublic)});
});
}
readonly()The readonly() rule prevents users from updating a field. The [FormField] directive automatically binds this state to the HTML readonly attribute, which prevents editing while still allowing users to focus and select text.
NOTE: Readonly fields skip validation.
To make a field permanently readonly, call readonly() with just the field path:
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-account',
imports: [FormField],
template: `
<label>
Username (cannot be changed)
<input [formField]="accountForm.username" />
</label>
<label>
Email
<input [formField]="accountForm.email" />
</label>
`,
})
export class Account {
accountModel = signal({
username: 'johndoe',
email: '[email protected]',
});
accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username);
});
}
The [FormField] directive automatically binds the readonly attribute based on the field's state.
To make a field readonly based on conditions, provide a when function:
import {Component, signal} from '@angular/core';
import {form, FormField, readonly} from '@angular/forms/signals';
@Component({
selector: 'app-document',
imports: [FormField],
template: `
<label>
<input type="checkbox" [formField]="documentForm.isLocked" />
Lock document
</label>
<label>
Document Title
<input [formField]="documentForm.title" />
</label>
`,
})
export class Document {
documentModel = signal({
isLocked: false,
title: 'Untitled',
});
documentForm = form(this.documentModel, (schemaPath) => {
readonly(schemaPath.title, {when: ({valueOf}) => valueOf(schemaPath.isLocked)});
});
}
When isLocked is true, the title field becomes readonly.
These three configuration functions control field availability in different ways:
Choose hidden() when the field:
Choose disabled() when the field:
Choose readonly() when the field:
All three skip validation and prevent user editing while active. The key differences:
| Feature | hidden() | disabled() | readonly() |
|---|---|---|---|
| Visible in UI | No | Yes | Yes |
| Users can focus/select | No | No | Yes |
| Included in HTML form submission | No | No | Yes |
debounce()The debounce() rule delays updating the form model. This is useful for performance optimization and reducing unnecessary operations during rapid input.
Without debouncing, every keystroke immediately updates the form model. This can trigger:
Debouncing delays these updates and reduces unnecessary work.
You can debounce a field by specifying a delay in milliseconds:
import {Component, signal} from '@angular/core';
import {form, FormField, debounce} from '@angular/forms/signals';
@Component({
selector: 'app-search',
imports: [FormField],
template: `
<label>
Search
<input [formField]="searchForm.query" />
</label>
<p>Searching for: {{ searchForm.query().value() }}</p>
`,
})
export class Search {
searchModel = signal({
query: '',
});
searchForm = form(this.searchModel, (schemaPath) => {
debounce(schemaPath.query, 300);
});
}
With a 300ms debounce:
The debounce() function ensures users don't lose data through these mechanisms:
This means users can type quickly, tab away, or submit the form without waiting for debounce delays to expire.
For advanced control, provide a debouncer function that controls when to synchronize the value. This function is called every time the control value is updated and can return either undefined to synchronize immediately, or a Promise that prevents synchronization until it resolves:
import {Component, signal} from '@angular/core';
import {form, FormField, debounce} from '@angular/forms/signals';
@Component({
selector: 'app-search',
imports: [FormField],
template: `
<label>
Search
<input [formField]="searchForm.query" />
</label>
`,
})
export class Search {
searchModel = signal({
query: '',
});
searchForm = form(this.searchModel, (schemaPath) => {
debounce(schemaPath.query, () => {
// Return a promise that resolves after 500ms
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
});
});
}
The debouncer function can return:
undefined to synchronize the value immediatelyPromise<void> that prevents synchronization until it resolvesUse cases for custom debounce logic:
Debouncing is most useful when:
Don't use debouncing if:
metadata()Metadata attaches reactive data to a field. Validation rules use this system internally, and you can publish your own keys for application-specific information like help text, configuration, or computed display values.
Signal Forms provides six pre-defined metadata keys that built-in validators populate automatically:
| Key | Populated by | Read via |
|---|---|---|
REQUIRED | required() | field().required() |
MIN | min() | field().min() |
MAX | max() | field().max() |
MIN_LENGTH | minLength() | field().minLength() |
MAX_LENGTH | maxLength() | field().maxLength() |
PATTERN | pattern() | field().pattern() |
The [formField] directive automatically binds five of these (REQUIRED, MIN, MAX, MIN_LENGTH, and MAX_LENGTH) to the corresponding HTML attribute on a native form control. PATTERN is the exception, because Signal Forms supports multiple patterns per field but the HTML pattern attribute accepts only a single regular expression.
import {Component, signal} from '@angular/core';
import {form, FormField, required, min, max} from '@angular/forms/signals';
@Component({
selector: 'app-age',
imports: [FormField],
template: `
<label>
Age (between {{ ageForm.age().min?.() }} and {{ ageForm.age().max?.() }})
<input type="number" [formField]="ageForm.age" />
</label>
@if (ageForm.age().required()) {
<span class="required-indicator">*</span>
}
`,
})
export class Age {
ageModel = signal({age: 0});
ageForm = form(this.ageModel, (schemaPath) => {
required(schemaPath.age);
min(schemaPath.age, 18);
max(schemaPath.age, 120);
});
}
Validation rules can derive their constraints from other fields, making the published metadata reactive:
import {Component, signal} from '@angular/core';
import {form, FormField, max} from '@angular/forms/signals';
@Component({
selector: 'app-inventory',
imports: [FormField],
template: `
<label>
Item
<select [formField]="inventoryForm.item">
<option value="widget">Widget</option>
<option value="gadget">Gadget</option>
</select>
</label>
<label>
Quantity (max: {{ inventoryForm.quantity().max?.() }})
<input type="number" [formField]="inventoryForm.quantity" />
</label>
`,
})
export class Inventory {
inventoryModel = signal({
item: 'widget',
quantity: 0,
});
inventoryForm = form(this.inventoryModel, (schemaPath) => {
max(schemaPath.quantity, ({valueOf}) => {
const item = valueOf(schemaPath.item);
return item === 'widget' ? 100 : 50;
});
});
}
The max() validation rule sets the MAX metadata reactively based on the selected item, so any template or control reading field().max() updates whenever the item changes.
For deeper coverage, including how to define custom keys, combine contributions with reducers, and use managed metadata for lifecycle-aware objects, see the Field metadata guide.
You can apply multiple rules to the same field, and you can use conditional logic to apply entire groups of rules based on form state.
Apply multiple rules to configure all aspects of a field's behavior:
import {Component, signal} from '@angular/core';
import {form, FormField, disabled, hidden, debounce, metadata} from '@angular/forms/signals';
import {PLACEHOLDER} from './metadata-keys';
@Component({
selector: 'app-promo',
imports: [FormField],
template: `
@if (!promoForm.promoCode().hidden()) {
<label>
Promo Code
<input [formField]="promoForm.promoCode" />
</label>
}
`,
})
export class Promo {
promoModel = signal({
hasAccount: false,
subscriptionType: 'free' as 'free' | 'premium',
promoCode: '',
});
promoForm = form(this.promoModel, (schemaPath) => {
disabled(schemaPath.promoCode, {
when: ({valueOf}) => (!valueOf(schemaPath.hasAccount) ? 'You must have an account' : false),
});
hidden(schemaPath.promoCode, {
when: ({valueOf}) => valueOf(schemaPath.subscriptionType) === 'free',
});
debounce(schemaPath.promoCode, 300);
metadata(schemaPath.promoCode, PLACEHOLDER, () => 'Enter promo code');
});
}
These rules work together:
Use applyWhen() to conditionally apply entire groups of rules:
import {Component, signal} from '@angular/core';
import {form, FormField, applyWhen, required, pattern} from '@angular/forms/signals';
@Component({
selector: 'app-address',
imports: [FormField],
template: `
<label>
Country
<select [formField]="addressForm.country">
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</label>
<label>
Zip/Postal Code
<input [formField]="addressForm.zipCode" />
</label>
`,
})
export class Address {
addressModel = signal({
country: 'US',
zipCode: '',
});
addressForm = form(this.addressModel, (schemaPath) => {
applyWhen(
schemaPath,
({valueOf}) => valueOf(schemaPath.country) === 'US',
(schemaPath) => {
// Only applied when country is US
required(schemaPath.zipCode);
pattern(schemaPath.zipCode, /^\d{5}(-\d{4})?$/);
},
);
});
}
The applyWhen() function receives:
true (apply) or false (don't apply)The conditional rules only run when the condition is true. This is useful for complex forms where validation rules or behavior changes based on user choices.
Extract common rule configurations into reusable functions:
import {SchemaPath, debounce, metadata, maxLength} from '@angular/forms/signals';
import {PLACEHOLDER} from './metadata-keys';
function emailFieldConfig(path: SchemaPath<string>) {
debounce(path, 300);
metadata(path, PLACEHOLDER, () => '[email protected]');
maxLength(path, 255);
}
// Use in multiple forms
const contactForm = form(contactModel, (schemaPath) => {
emailFieldConfig(schemaPath.email);
emailFieldConfig(schemaPath.alternateEmail);
});
const registrationForm = form(registrationModel, (schemaPath) => {
emailFieldConfig(schemaPath.email);
});
This pattern is useful when you have standard field configurations that you use across multiple forms in your application.
To learn more about Signal Forms, check out these related guides: