Mastering Angular 21 Signal Forms: A Deep Dive into the Experimental API
Angular 21 introduces one of the most significant improvements to form handling since the framework's inception: Signal-Based Forms (Experimental).
Introduction
Traditional Angular forms, while powerful, have long suffered from several pain points:
Verbose boilerplate with FormBuilder, FormGroup, and FormControl
Complex state management requiring manual subscriptions
Performance overhead from Observable chains
Cumbersome validation error handling
Difficult form synchronization with component state
In this comprehensive guide, we’ll transform a real-world weather chatbot application from reactive forms to Signal Forms, showcasing:
Complete migration strategies
Performance improvements
Advanced validation techniques
Best practices for Signal Forms
When and how to adopt this new approach
You can find the source code of the Weather ChatBot App here.
Reactive Forms Pain Points
Let’s examine our weather chatbot application to understand the current challenges with reactive forms.
The Weather Chatbot Example
Our application features a weather query form with the following fields:
Date: When to check the weather
Country: Location country
City: Specific city
Temperature Unit: Celsius or Fahrenheit preference
Here’s the current reactive forms implementation:
// weather-chatbot.component.ts
@Component({
selector: ‘app-weather-chatbot’,
templateUrl: ‘./weather-chatbot.component.html’,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WeatherChatbotComponent {
private readonly _formBuilder = inject(FormBuilder);
private readonly _chatService = inject(ChatService);
protected readonly messages = signal<ChatMessage[]>([]);
protected readonly isSubmitting = signal(false);
protected readonly messageCount = computed(() => this.messages().length);
protected readonly formValue = computed(() => this.weatherForm.value);
protected readonly isDevelopment = signal(false);
// Traditional Reactive Form
protected readonly weatherForm: FormGroup = this._formBuilder.group({
date: [’‘, Validators.required],
country: [’‘, [Validators.required, Validators.minLength(2)]],
city: [’‘, [Validators.required, Validators.minLength(2)]],
temperatureUnit: [’celsius’, Validators.required] as [TemperatureUnit, any],
});
constructor() {
// Manual form initialization
const today = new Date().toISOString().split(’T’)[0];
this.weatherForm.patchValue({ date: today });
}
protected onSubmitWeatherQuery(): void {
// Manual form validation handling
if (this.weatherForm.invalid) {
this.weatherForm.markAllAsTouched();
return;
}
const formData = this.weatherForm.value as WeatherFormData;
const query = this._buildWeatherQuery(formData);
this._addUserMessage(query);
this._sendMessageToAI(query);
}
}
The Template Pain Points
The template reveals additional complexity:
<!-- Complex validation error handling -->
@if (weatherForm.get(’country’)?.errors && weatherForm.get(’country’)?.touched) {
<p class=”text-red-500 text-xs mt-1”>
@if (weatherForm.get(’country’)?.errors?.[’required’]) {
Country is required
}
@if (weatherForm.get(’country’)?.errors?.[’minlength’]) {
Country must be at least 2 characters
}
</p>
}
<!-- Verbose form control access -->
<input
id=”country”
type=”text”
formControlName=”country”
placeholder=”e.g., United States”
class=”w-full px-3 py-2 border border-gray-300 rounded-lg...”
/>
Identified Pain Points
Boilerplate Overload: FormBuilder, FormGroup, manual validation setup
Type Safety Issues:
weatherForm.value as WeatherFormData
casting requiredComplex Error Handling: Nested conditionals for validation messages
State Synchronization: Manual form value computed signals
Verbose Template Logic: Repeated
weatherForm.get()
callsMixed Paradigms: Signals for app state, Observables for forms
Signal Forms API Explained
Before diving into the migration, understanding the core Signal Forms API is essential. This section covers the key functions, types, and patterns you’ll use when building forms with Angular 21’s experimental Signal Forms.
Here is a diagram showing a high-level overview of Angular signal forms.
Core Form Creation
form<TValue>(model, schema?, options?)
Creates a Signal Form bound to a data model. Updating the FieldState
(form fields) updates also the model.
Parameters:
model: WritableSignal<TValue>
- The data signal that serves as the source of truthschema?: SchemaOrSchemaFn<TValue>
- Optional validation and logic rulesoptions?: FormOptions
- Optional configuration (injector, name, adapter)
Returns: Field<TValue>
- A reactive field tree matching your data structure
Example:
1. Basic Form (No Schema)
const data = signal({
username: ‘’,
email: ‘’
});
const basicForm = form(data);
// Access fields
basicForm.username().value(); // Read value
basicForm.username().value.set(’john’); // Write value
2. Form with Inline Schema Function (Most common pattern for defining validation inline)
const userForm = form(
signal({ username: ‘’, email: ‘’, age: 0 }),
(path) => {
// path represents the root of your data structure
required(path.username);
required(path.email);
email(path.email);
min(path.age, 18, { message: ‘Must be 18 or older’ });
}
);
The path
parameter:
Type:
FieldPath<YourDataType>
Represents the root field
Provides type-safe navigation to all nested properties
Used to target which fields get which rules
The schema function parameter is called path
because it represents a location or path in your data structure. Think of it like navigating a file system:
// Just like file paths:
// /user/profile/name
// /user/profile/email
// Signal Forms paths:
// path.user.profile.name
// path.user.profile.email
The path
object is a proxy that mirrors your data model’s structure. When you write path.username
, you’re not accessing actual data—you’re defining where in the form tree to apply validation rules.
// This is NOT data access:
const myForm = form(data, (path) => {
required(path.username); // path.username is a “path marker”, not a value
});
// This IS data access:
const actualValue = myForm.username().value(); // Reading the actual data
path
is directly related to PathKind
. It’s a type-level marker that Angular uses to ensure you’re using validators and logic functions correctly based on where in the form tree they’re applied.
What is PathKind?
PathKind
classifies paths into three categories based on their position in the form structure:
type PathKind =
| PathKind.Root // Top-level form path
| PathKind.Child // Nested property path
| PathKind.Item // Array element path
PathKind.Root - The Entry Point
This is the
path
parameter you receive in your main schema function:const myForm = form(signal(data), (path) => { // ↑ path is PathKind.Root // This is the root of your form tree required(path.username); // username is Child required(path.email); // email is Child });
Characteristics:
The starting point of navigation
Accepts functions designed for Root, Child, or Item paths
PathKind.Child - Nested Properties
Any property accessed from another path becomes a
Child
:type User = { profile: { firstName: string; lastName: string; }; }; const userForm = form(signal<User>(...), (path) => { // path = Root // path.profile = Child // path.profile.firstName = Child // path.profile.lastName = Child required(path.profile.firstName); // Child path });
Characteristics:
Accessed via dot notation from another path
Represents a specific property in the data structure
Can be further navigated if the value is an object
PathKind.Item - Array Elements
Array items get special treatment with
PathKind.Item
:type TodoList = { todos: Array<{ title: string; done: boolean; }>; }; const todoForm = form(signal<TodoList>(...), (path) => { // path = Root // path.todos = Child (the array itself) applyEach(path.todos, (itemPath) => { // itemPath = Item (one element in the array) // itemPath.title = Child (of the Item) // itemPath.done = Child (of the Item) required(itemPath.title); // itemPath has access to special item context }); });
Characteristics:
Only created through
applyEach
Represents a single element in an array
Has access to additional context (like index)
3. Form with Predefined Schema (Reusable schemas for consistent validation across forms):
// Define schema once
const userSchema = schema<User>((path) => {
required(path.username);
minLength(path.username, 3);
email(path.email);
});
// Reuse across multiple forms
const registrationForm = form(signal(newUser), userSchema);
const profileForm = form(signal(currentUser), userSchema);
4. Form with Options
const myForm = form(
signal(data),
(path) => {
required(path.name);
},
{
injector: customInjector, // Custom DI context
name: ‘user-registration’, // Form identifier for debugging
adapter: customFieldAdapter // Advanced customization
}
);
Understanding the adapter
Option
The adapter
option allows you to customize how fields are created and managed internally. This is an advanced, low-level API that most developers will never need to touch.
What does an adapter do?
It controls the internal lifecycle of form fields by implementing the FieldAdapter
interface:
interface FieldAdapter {
// How to create the field structure (parent-child relationships)
createStructure(node: FieldNode, options: FieldNodeOptions): FieldNodeStructure;
// How to create validation state (errors, valid, pending, etc.)
createValidationState(node: FieldNode, options: FieldNodeOptions): ValidationState;
// How to create field state (touched, dirty, disabled, etc.)
createNodeState(node: FieldNode, options: FieldNodeOptions): FieldNodeState;
// How to create child field nodes
newChild(options: ChildFieldNodeOptions): FieldNode;
// How to create root field nodes
newRoot<TValue>(
fieldManager: FormFieldManager,
model: WritableSignal<TValue>,
pathNode: FieldPathNode,
adapter: FieldAdapter
): FieldNode;
}
When Would You Use a Custom Adapter?
1. Reactive Forms Compatibility Layer
This is the primary use case mentioned in the source code. If you’re migrating from reactive forms and need both systems to coexist:
// Hypothetical compatibility adapter
class ReactiveFormsAdapter implements FieldAdapter {
createValidationState(node: FieldNode, options: FieldNodeOptions): ValidationState {
// Return a validation state that also updates reactive forms validators
return new HybridValidationState(node, this.reactiveFormControl);
}
// ... other methods
}
const hybridForm = form(
signal(data),
schema,
{ adapter: new ReactiveFormsAdapter(existingFormGroup) }
);
2. Testing and Debugging
Create adapters that expose additional debugging information:
class DebugAdapter implements FieldAdapter {
private _fieldLog = new Map<string, any[]>();
newRoot<TValue>(
fieldManager: FormFieldManager,
model: WritableSignal<TValue>,
pathNode: FieldPathNode,
adapter: FieldAdapter
): FieldNode {
const node = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
// Track all field accesses
this._fieldLog.set(’root’, []);
effect(() => {
this.fieldLog.get(’root’)?.push({
timestamp: Date.now(),
value: model()
});
});
return node;
}
// ... other methods
}
// Use in tests
const testForm = form(signal(data), schema, {
adapter: new DebugAdapter()
});
schema<TValue>(fn
: SchemaFn<TValue>)
Creates a reusable schema (adds logic rules to a form) that can be applied to multiple forms or composed with other schemas.
Parameters:
fn: SchemaFn<TValue>
- A function that defines validation and logic rules (non-reactive function)
Returns: Schema<TValue>
- A reusable schema object
Example:
const addressSchema = schema<Address>((path) => {
required(path.street);
required(path.city);
minLength(path.zipCode, 5);
});
Field Types
Field<TValue, TKey>
Represents a single field in the form. Acts as both a function and an object with subfields.
Key characteristics:
Call as function to access state:
myForm()
returnsFieldState<TValue>
Navigate as object:
myForm.name
accesses nested fieldsArrays are iterable:
for (let item of myForm.items)
FieldState<TValue, TKey>
The reactive state of a field, accessed by calling the field as a function.
Core properties:
interface FieldState<TValue> {
value: WritableSignal<TValue>; // Read/write the field value
errors: Signal<ValidationError[]>; // Current validation errors
errorSummary: Signal<ValidationError[]>; // Errors including descendants
valid: Signal<boolean>; // True if no errors and no pending validators
invalid: Signal<boolean>; // True if has errors (regardless of pending)
pending: Signal<boolean>; // True if async validators running
touched: Signal<boolean>; // True if field has been blurred
dirty: Signal<boolean>; // True if value has been changed
disabled: Signal<boolean>; // True if field is disabled
readonly: Signal<boolean>; // True if field is readonly
hidden: Signal<boolean>; // True if field is hidden
submitting: Signal<boolean>; // True if form is submitting
name: Signal<string>; // Unique field name
// Methods
markAsTouched(): void;
markAsDirty(): void;
reset(): void;
property<M>(prop: Property<M> | AggregateProperty<M, any>): M | Signal<M>;
}
Control Binding
Control
Directive
Binds a Field
to a UI control element.
Usage:
<input [control]=”myForm.fieldName” />
<textarea [control]=”myForm.description” />
<select [control]=”myForm.category” />
<input type=”checkbox” [control]=”myForm.accepted” />
<input type=”radio” [control]=”myForm.option” value=”a” />
Features:
Automatically syncs field value with control
Binds validation state (invalid, touched, etc.)
Handles disabled/readonly/hidden states
Works with native inputs and custom controls
Provides fake
NgControl
for reactive forms compatibility
Built-in Validators
All validators follow this pattern: validator(path, value?, config?)
required(path, config?)
Ensures field has a non-empty value.
required(path.name);
required(path.email, { message: ‘Email is required’ });
required(path.terms, {
when: (ctx) => ctx.valueOf(path.needsConsent)
});
Also sets: REQUIRED
aggregate property
minLength(path, length, config?)
Validates minimum string/array length.
minLength(path.password, 8);
minLength(path.username, 3, { message: ‘Too short’ });
Also sets: MIN_LENGTH
aggregate property
maxLength(path, length, config?)
Validates maximum string/array length.
maxLength(path.bio, 500);
Also sets: MAX_LENGTH
aggregate property
min(path, value, config?)
Validates minimum numeric value.
min(path.age, 18);
min(path.price, 0, { message: ‘Price cannot be negative’ });
Also sets: MIN
aggregate property
max(path, value, config?)
Validates maximum numeric value.
max(path.quantity, 100);
Also sets: MAX
aggregate property
pattern(path, regex, config?)
Validates against a regular expression.
pattern(path.phone, /^\d{3}-\d{3}-\d{4}$/);
Also sets: PATTERN
aggregate property
email(path, config?)
Validates email format.
email(path.email);
email(path.email, { message: ‘Invalid email address’ });
Custom Validation
validate(path, validator)
Adds a custom synchronous validator for a single field.
validate(path.username, (ctx) => {
const value = ctx.value();
if (value.includes(’ ‘)) {
return customError({
kind: ‘no_spaces’,
message: ‘Username cannot contain spaces’
});
}
return null; // No error
});
Validator return types:
null | undefined | void
- No errorValidationError
- Single errorValidationError[]
- Multiple errors
validateTree(path, validator)
Adds a validator that can target multiple fields.
validateTree(path, (ctx) => {
const from = ctx.field.from().value();
const to = ctx.field.to().value();
if (from === to) {
return {
kind: ‘same_location’,
field: ctx.field.from, // Target specific field
message: ‘Departure and arrival cannot be the same’
};
}
return null;
});
validateAsync(path, options)
For validation that requires server calls or time-consuming operations, use async validators.
import { rxResource } from ‘@angular/core/rxjs-interop’;
import { of, delay, map } from ‘rxjs’;
validateAsync(path.username, {
// Map field state to resource parameters
params: (ctx) => ({
username: ctx.value()
}),
// Create resource with those parameters
factory: (params) => {
return rxResource({
request: () => params().username,
loader: ({ request: username }) => {
// Simulate API call
return of(null).pipe(
delay(1000),
map(() => checkUsernameAvailability(username))
);
}
});
},
// Map resource result to errors
errors: (result, ctx) => {
if (!result.available) {
return customError({
kind: ‘username_taken’,
message: `Username “${ctx.value()}” is already taken`,
suggestions: result.suggestions
});
}
return null;
}
});
validateHttp(path, options)
Simplified async validation for HTTP requests.
validateHttp(path.email, {
// Return URL or HttpResourceRequest
request: (ctx) => ({
url: ‘/api/validate-email’,
params: { email: ctx.value() }
}),
// Map response to errors
errors: (result, ctx) => {
if (!result.valid) {
return customError({
kind: ‘invalid_email_server’,
message: result.message || ‘Email validation failed’,
details: result.details
});
}
return null;
},
// Optional HttpResource options
options: {
reloadOn: [’submitted’] // Only revalidate on form submit
}
});
Schema Composition
apply(path, schema)
Applies a schema to a specific field path.
const addressSchema = schema<Address>((path) => {
required(path.street);
required(path.city);
});
form(data, (path) => {
apply(path.address, addressSchema);
});
applyEach(path, schema)
Applies a schema to each item in an array.
const itemSchema = schema<Item>((path) => {
required(path.name);
min(path.quantity, 1);
});
form(data, (path) => {
applyEach(path.items, itemSchema);
});
applyWhen(path, condition, schema)
Conditionally applies a schema based on form state.
// Only validate shipping address if different from billing
applyWhen(
path.shippingAddress,
(ctx) => !ctx.valueOf(path.sameAsBilling),
addressSchema
);
applyWhenValue(path, predicate, schema)
Conditionally applies a schema based on field value.
type PaymentMethod =
| { type: ‘card’; cardNumber: string; cvv: string }
| { type: ‘paypal’; email: string }
| { type: ‘bank’; accountNumber: string; routingNumber: string };
// Type-safe conditional schemas
applyWhenValue(
path.payment,
(payment): payment is Extract<PaymentMethod, { type: ‘card’ }> =>
payment.type === ‘card’,
(cardPath) => {
required(cardPath.cardNumber);
minLength(cardPath.cardNumber, 16);
maxLength(cardPath.cardNumber, 16);
required(cardPath.cvv);
pattern(cardPath.cvv, /^\d{3,4}$/);
}
);
applyWhenValue(
path.payment,
(payment): payment is Extract<PaymentMethod, { type: ‘paypal’ }> =>
payment.type === ‘paypal’,
(paypalPath) => {
required(paypalPath.email);
email(paypalPath.email);
}
);
Field State Logic
disabled(path, logic?)
Makes a field disabled.
disabled(path.endDate, (ctx) => !ctx.valueOf(path.hasEndDate));
readonly(path, logic?)
Makes a field readonly.
readonly(path.id); // Always readonly
readonly(path.price, (ctx) => ctx.valueOf(path.isLocked));
hidden(path, logic)
Hides a field from display and validation.
hidden(path.optionalDetails, (ctx) => !ctx.valueOf(path.showDetails));
Form Submission
submit(form, action)
Handles form submission with automatic validation and error handling.
const onSubmit = submit(myForm, async (form) => {
try {
await saveData(form().value());
return null; // Success
} catch (error) {
return [{
kind: ‘save_error’,
message: ‘Failed to save’,
field: form
}];
}
});
Template usage:
<form (ngSubmit)=”onSubmit()”>
<!-- form fields -->
</form>
Validation Errors
Creating Errors
Signal Forms provides type-safe error creation functions:
// Built-in errors
requiredError({ message: ‘This field is required’ })
minError(10, { message: ‘Must be at least 10’ })
maxError(100, { message: ‘Cannot exceed 100’ })
minLengthError(5, { message: ‘Too short’ })
maxLengthError(50, { message: ‘Too long’ })
patternError(/\d+/, { message: ‘Must contain numbers’ })
emailError({ message: ‘Invalid email format’ })
// Custom errors
customError({
kind: ‘my_validation’,
message: ‘Custom validation failed’,
additionalData: ‘any value’
})
Error Types
interface ValidationError {
kind: string; // Error identifier
field: Field<unknown>; // Target field
message?: string; // User-facing message
}
// Specific error types
interface RequiredValidationError extends ValidationError {
kind: ‘required’;
}
interface MinValidationError extends ValidationError {
kind: ‘min’;
min: number;
}
// Check error type
if (error instanceof NgValidationError) {
switch (error.kind) {
case ‘required’: /* ... */
case ‘min’: /* ... */
}
}
Custom Controls (no longer need ControlValueAccessor)
To create custom form controls, implement FormValueControl<T>
:
@Component({
selector: ‘app-custom-input’,
template: `<div>Custom control</div>`
})
export class CustomInputComponent implements FormValueControl<string> {
value = model(’‘); // Required
disabled = input(false); // Optional
errors = input<ValidationError[]>([]); // Optional
readonly = input(false); // Optional
touched = model(false); // Optional
}
Usage:
<app-custom-input [control]=”myForm.field” />
Aggregate Properties
Aggregate properties allow validators to contribute metadata to fields:
// Read built-in properties
myForm.field().property(REQUIRED); // boolean
myForm.field().property(MIN_LENGTH); // number | undefined
myForm.field().property(MAX); // number | undefined
myForm.field().property(PATTERN); // RegExp[]
// Create custom properties
const TOOLTIP = createProperty<string>();
property(path.field, TOOLTIP, () => ‘Help text here’);
// Read in template
{{ myForm.field().property(TOOLTIP) }}
Type Safety
Signal Forms maintain full TypeScript type inference:
type User = {
profile: {
name: string;
age: number;
};
tags: string[];
};
const userForm = form(signal<User>(...));
userForm.profile.name // Field<string>
userForm.profile.age // Field<number>
userForm.tags // Field<string[]> & Iterable
userForm.tags[0] // Field<string>
This API overview provides the foundation for understanding how Signal Forms work. The next section will show how to migrate from reactive forms using these APIs.
Enter Signal Forms: A New Paradigm
Signal Forms treats your data model as the single source of truth, with forms being a reactive view of that model.
Core Philosophy
// Traditional Reactive Forms: Form manages state
const form = this.formBuilder.group({
name: [’‘, Validators.required]
});
// Signal Forms: Data model is the source of truth
const user = signal({ name: ‘’ });
const userForm = form(user); // Form reflects the signal
Step-by-Step Migration Guide - Source Code
Let’s transform our weather chatbot from reactive forms to Signal Forms.
Step 1: Define the Data Model
First, we establish our data model as the source of truth:
// weather-chatbot-signal.component.ts
import { Component, signal, computed, inject, ChangeDetectionStrategy } from ‘@angular/core’;
import { form, Control, required, minLength, submit } from ‘@angular/forms/signals’;
import { CommonModule } from ‘@angular/common’;
type WeatherFormData = {
date: string;
country: string;
city: string;
temperatureUnit: ‘celsius’ | ‘fahrenheit’;
};
@Component({
selector: ‘app-weather-chatbot-signal’,
imports: [CommonModule, Control], // Note: Control instead of ReactiveFormsModule
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...` // We’ll update this next
})
export class WeatherChatbotSignalComponent {
private readonly _chatService = inject(ChatService);
// Step 1: Data model as source of truth
private readonly _weatherData = signal<WeatherFormData>({
date: new Date().toISOString().split(’T’)[0],
country: ‘’,
city: ‘’,
temperatureUnit: ‘celsius’
});
// Step 2: Create Signal Form
protected readonly weatherForm = form(this._weatherData, (path) => {
required(path.date, { message: ‘Date is required’ });
required(path.country, { message: ‘Country is required’ });
required(path.city, { message: ‘City is required’ });
minLength(path.country, 2, { message: ‘Country must be at least 2 characters’ });
minLength(path.city, 2, { message: ‘City must be at least 2 characters’ });
required(path.temperatureUnit, { message: ‘Temperature unit is required’ });
});
// Other signals remain the same
protected readonly messages = signal<ChatMessage[]>([]);
protected readonly isSubmitting = signal(false);
protected readonly messageCount = computed(() => this.messages().length);
protected shouldShowErrors(fieldErrors: any[], fieldTouched: boolean): boolean {
return fieldErrors.length > 0 && fieldTouched;
}
}
Step 2: Update the Template
The template becomes dramatically simpler:
<!-- Signal Forms Template -->
<form class=”space-y-4”>
<!-- Date Input - Clean and Simple -->
<div>
<label for=”date” class=”block text-sm font-medium text-gray-700 mb-1”>
Date
</label>
<input
id=”date”
type=”date”
[control]=”weatherForm.date”
class=”w-full px-3 py-2 border border-gray-300 rounded-lg...”
/>
@if (shouldShowErrors(weatherForm.city().errors(), weatherForm.city().touched())) {
@for (error of weatherForm.city().errors(); track $index) {
<p class=”text-red-500 text-xs mt-1”>{{ error.message || ‘City is invalid’ }}</p>
}
}
</div>
<!-- Same for other fields... -->
<button
type=”button”
(click)=”onSubmitWeatherQuery()”
[disabled]=”!weatherForm().valid() || isSubmitting()”
class=”w-full bg-blue-600 text-white py-2 px-4 rounded-lg...”
>
@if (isSubmitting()) {
<span class=”flex items-center justify-center”>
<svg class=”animate-spin -ml-1 mr-2 h-4 w-4 text-white” ...>
<!-- Loading spinner -->
</svg>
Getting Weather...
</span>
} @else {
🌤️ Ask About Weather
}
</button>
</form>
Step 3: Update Form Submission
You can do it by a custom function (button type) or with the submit
feature:
// With type="button" and (click)="onSubmitWeatherQuery()"
protected onSubmitWeatherQuery(): void {
if (!this.weatherForm().valid()) {
this._markAllFieldsAsTouched();
return;
}
const formData = this._weatherData();
const query = this._buildWeatherQuery(formData);
this._addUserMessage(query);
this._sendMessageToAI(query);
}
private _markAllFieldsAsTouched(): void {
this.weatherForm.date().markAsTouched();
this.weatherForm.country().markAsTouched();
this.weatherForm.city().markAsTouched();
this.weatherForm.temperatureUnit().markAsTouched();
}
// With (submit)="onSubmit($event)" and type="submit" on the button
protected readonly onSubmit = submit(this.weatherForm, (data) => {
const query = this._buildWeatherQuery(data);
this._addUserMessage(query);
this._sendMessageToAI(query);
});
Dynamic Signal Form Arrays - Source Code
Real-world applications often require managing collections of data. In our Weather Assistant, we might want users to query multiple locations simultaneously. Signal Forms handles dynamic arrays elegantly through the applyEach
function, which applies validation schemas to each array element.
Implementing Multi-Location Support
Let’s extend our weather application to support multiple locations. First, we’ll refactor the data model:
Updated Type Definitions:
type WeatherLocation = {
city: string;
country: string;
};
type WeatherFormData = {
date: string;
locations: WeatherLocation[]; // Array instead of single city/country
temperatureUnit: TemperatureUnit;
};
Modified Component (weather-chatbot.component.ts):
@Component({
selector: 'app-weather-chatbot',
templateUrl: ‘./weather-chatbot.component.html’,
imports: [CommonModule, Control, JsonPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WeatherChatbotComponent {
// Update data model to use locations array
private readonly _weatherData = signal<WeatherFormData>({
date: new Date().toISOString().split(’T’)[0],
locations: [{ city: ‘’, country: ‘’ }], // Start with one location
temperatureUnit: ‘celsius’,
});
// Apply validation to each location using applyEach
protected readonly weatherForm = form(this._weatherData, (path) => {
required(path.date, { message: ‘Date is required’ });
// The key change: applyEach creates a schema for each array element
applyEach(path.locations, (location) => {
// ‘location’ is PathKind.Item - represents one element
required(location.city, { message: ‘City is required’ });
minLength(location.city, 2, { message: ‘City must be at least 2 characters’ });
required(location.country, { message: ‘Country is required’ });
minLength(location.country, 2, { message: ‘Country must be at least 2 characters’ });
});
required(path.temperatureUnit, { message: ‘Temperature unit is required’ });
});
// Add new location to the array
protected addLocation(): void {
this._weatherData.update(data => ({
...data,
locations: [...data.locations, { city: ‘’, country: ‘’ }]
}));
}
// Remove location by index
protected removeLocation(index: number): void {
this._weatherData.update(data => ({
...data,
locations: data.locations.filter((_, i) => i !== index)
}));
}
// Update to mark all location fields as touched
private _markAllFieldsAsTouched(): void {
this.weatherForm.date().markAsTouched();
this.weatherForm.temperatureUnit().markAsTouched();
// Iterate through array fields
for (const location of this.weatherForm.locations) {
location.city().markAsTouched();
location.country().markAsTouched();
}
}
// Update query builder to handle multiple locations
private _buildWeatherQuery(data: WeatherFormData): string {
const date = new Date(data.date).toLocaleDateString(’en-US’, {
weekday: ‘long’,
year: ‘numeric’,
month: ‘long’,
day: ‘numeric’,
});
const unit = data.temperatureUnit === ‘celsius’ ? ‘°C’ : ‘°F’;
// Format multiple locations
const locationsList = data.locations
.map(loc => `${loc.city}, ${loc.country}`)
.join(’ and ‘);
return `What’s the weather forecast for ${locationsList} on ${date}? Please provide the temperature in ${unit}.`;
}
}
Template Changes
The template uses Angular’s @for
to iterate over the locations array. Each location gets its own set of fields bound to the corresponding array element:
<!-- Locations Array -->
<div class=”space-y-3”>
<label class=”block text-sm font-medium text-gray-700”>Locations</label>
@for (location of weatherForm.locations; track $index; let i = $index) {
<div class=”border border-gray-200 rounded-lg p-3 space-y-3”>
<div class=”flex justify-between items-center”>
<span class=”text-sm font-medium text-gray-600”>Location {{ i + 1 }}</span>
@if (weatherForm.locations.length > 1) {
<button
type=”button”
(click)=”removeLocation(i)”
class=”text-red-600 hover:text-red-700 text-sm”
>
Remove
</button>
}
</div>
<!-- City Field -->
<div>
<label [attr.for]=”’city-’ + i” class=”block text-xs font-medium text-gray-600 mb-1”>
City
</label>
<input
[id]=”’city-’ + i”
type=”text”
[control]=”weatherForm.locations[i].city”
placeholder=”e.g., New York”
class=”w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500”
/>
@if (shouldShowErrors(weatherForm.locations[i].city().errors(),
weatherForm.locations[i].city().touched())) {
@for (error of weatherForm.locations[i].city().errors(); track error) {
<p class=”text-red-500 text-xs mt-1”>{{ error.message }}</p>
}
}
</div>
<!-- Country Field -->
<div>
<label [attr.for]=”’country-’ + i” class=”block text-xs font-medium text-gray-600 mb-1”>
Country
</label>
<input
[id]=”’country-’ + i”
type=”text”
[control]=”weatherForm.locations[i].country”
placeholder=”e.g., United States”
class=”w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500”
/>
@if (shouldShowErrors(weatherForm.locations[i].country().errors(),
weatherForm.locations[i].country().touched())) {
@for (error of weatherForm.locations[i].country().errors(); track error) {
<p class=”text-red-500 text-xs mt-1”>{{ error.message }}</p>
}
}
</div>
</div>
}
<button
type=”button”
(click)=”addLocation()”
class=”w-full border-2 border-dashed border-gray-300 rounded-lg p-3 text-gray-600 hover:border-blue-500 hover:text-blue-600 text-sm”
>
+ Add Another Location
</button>
</div>
Real-World Weather API Example - Source Code
// Validate city exists using weather API
validateAsync(location.city, {
params: (ctx) => {
const city = ctx.value();
const country = ctx.fieldOf(location.country)().value();
if (!city || city.length < 2 || !country || country.length < 2) {
return undefined;
}
return { city, country };
},
factory: (params) => {
return rxResource({
params,
stream: (p) => {
if (!p.params) return of(null);
const { city, country } = p.params;
const cacheKey = this._getCacheKey(city, country);
// Check cache first
if (this._cityValidationCache.has(cacheKey)) {
console.log(`Using cached result for ${cacheKey}`);
return of(this._cityValidationCache.get(cacheKey));
}
const apiKey = this._config.get(’WEATHER_API_KEY’);
const url = `https://api.weatherapi.com/v1/search.json?key=${apiKey}&q=${encodeURIComponent(
city
)},${encodeURIComponent(country)}`;
return of(null).pipe(
delay(2000),
switchMap(() => this._http.get(url)),
tap((results) => {
// Store in cache after successful fetch
this._cityValidationCache.set(cacheKey, results);
})
);
},
});
},
errors: (results, ctx) => {
console.log(results);
if (!results || results.length === 0) {
return customError({
kind: ‘city_not_found’,
message: `Could not find “${ctx.value()}” in weather database`,
});
}
const exactMatch = results.some(
(r: any) =>
r.name.toLowerCase() === ctx.value().toLowerCase() &&
r.country.toLowerCase() === ctx.fieldOf(location.country)().value().toLowerCase()
);
if (!exactMatch) {
return customError({
kind: ‘city_country_mismatch’,
message: `”${ctx.value()}” does not exist in ${ctx
.fieldOf(location.country)()
.value()}`,
});
}
return null;
},
});
Async Validator Behavior
Only runs after all sync validators pass
Field shows
pending()
===true
while async validation runsUpdates automatically when dependencies change
Can be debounced or throttled using resource options
<!-- Show pending state -->
@if (weatherForm.city().pending()) {
<span class=”text-blue-500 text-xs”>Verifying city...</span>
}
@if (weatherForm.city().errors().length > 0) {
@for (error of weatherForm.city().errors(); track error) {
<p class=”text-red-500 text-xs”>{{ error.message }}</p>
}
}
Cross-Field Validation with validate()
- Source Code
Validate relationships between sibling fields by validating a parent:
validate(path, (ctx) => {
const locations = ctx.value().locations;
if (locations.length === 2) {
const [first, second] = locations;
if (first.city === second.city && first.country === second.country) {
return customError({
kind: ‘same_locations’,
message: ‘Locations must be different’
});
}
}
return null;
});
Tree Validators with validateTree() - Source Code
While validate()
works well for single-field validation and simple cross-field checks, it has a critical limitation: errors can only be assigned to the field being validated. When you need to validate relationships across multiple fields and target errors to specific locations in your form tree, validateTree()
is the solution.
With validate()
, you can detect duplicates, but the error appears on the parent field:
// Using validate() - error shows on the root form, not specific fields
validate(path, (ctx) => {
const locations = ctx.value().locations;
// ... duplicate detection logic
return customError({
kind: ‘duplicate_location’,
message: ‘You have duplicate locations’
// Error appears on the form root, not helpful for users
});
});
With validateTree()
, you can target errors to the exact duplicate fields:
// Using validateTree() - errors appear on duplicate city fields
validateTree(path, (ctx) => {
const errors: any[] = [];
const locations = ctx.value().locations;
locations.forEach((location, index) => {
const city = location.city.valueOf();
const country = location.country.valueOf();
if (!city || !country) return; // Skip empty values
locations.forEach((otherLocation, otherIndex) => {
if (index !== otherIndex) {
if (
city === otherLocation.city.valueOf() &&
country === otherLocation.country.valueOf()
) {
errors.push({
kind: ‘duplicate_location’,
field: ctx.field.locations[index].city, // Target specific field!
message: `Duplicate location: ${city}, ${country}`,
});
}
}
});
});
return errors.length > 0 ? errors : null;
});
Standard Schema Integration with Zod - Source Code
As applications grow, maintaining consistent validation rules across client and server becomes challenging. Signal Forms addresses this with validateStandardSchema()
, allowing integration with popular schema validation libraries like Zod, Yup, and Valibot. This section demonstrates Zod integration in our weather application.
Why Standard Schema Validation?
While Signal Forms’ built-in validators are powerful, standard schema libraries offer several advantages:
Single Source of Truth
// Define once, use everywhere
const weatherFormSchema = z.object({
date: z.string().min(1),
city: z.string().min(2).max(50),
// ... share between client and server
});
Type Inference
// TypeScript types automatically generated from schema
type WeatherFormData = z.infer<typeof weatherFormSchema>;
Setting Up Zod Schemas
First, install Zod:
npm install zod
Create a dedicated file for your schemas:
weather-form.schemas.ts
import { z } from ‘zod’;
export const weatherLocationSchema = z.object({
city: z.string()
.min(2, ‘City must be at least 2 characters’)
.max(50, ‘City name is too long’),
country: z.string()
.min(2, ‘Country must be at least 2 characters’)
.max(50, ‘Country name is too long’)
});
export const weatherFormSchema = z.object({
date: z.string()
.min(1, ‘Date is required’)
.refine((date) => {
const selectedDate = new Date(date);
const today = new Date();
today.setHours(0, 0, 0, 0);
return selectedDate >= today;
}, {
message: ‘Date cannot be in the past’
}),
locations: z.array(weatherLocationSchema)
.min(1, ‘At least one location is required’)
.max(5, ‘Maximum 5 locations allowed’),
temperatureUnit: z.enum([’celsius’, ‘fahrenheit’], {
errorMap: () => ({ message: ‘Temperature unit is required’ })
})
});
Integration with Signal Forms (Hybrid Approach with Zod)
import { validateStandardSchema } from ‘@angular/forms/signals’;
import { weatherFormSchema } from ‘./weather-form.schemas’;
protected readonly weatherForm = form(this._weatherData, (path) => {
// Single line replaces all basic validation!
validateStandardSchema(path, weatherFormSchema);
// Keep custom validators for Angular-specific logic
applyEach(path.locations, (location) => {
// Async validation for API calls
validateAsync(location.city, {
params: (ctx) => {
const city = ctx.value();
const country = ctx.fieldOf(location.country)().value();
if (!city || city.length < 2 || !country || country.length < 2) {
return undefined;
}
return { city, country };
},
factory: (params) => {
return rxResource({
params,
stream: (p) => {
if (!p.params) return of(null);
const { city, country } = p.params;
const apiKey = this._config.get(’WEATHER_API_KEY’);
const url = `https://api.weatherapi.com/v1/search.json?key=${apiKey}&q=${city},${country}`;
return this._http.get(url);
},
});
},
errors: (results, ctx) => {
if (!results || results.length === 0) {
return customError({
kind: ‘city_not_found’,
message: `Could not find “${ctx.value()}” in weather database`,
});
}
return null;
},
});
});
// Tree validation for complex cross-field logic
validateTree(path, (ctx) => {
const errors: any[] = [];
const locations = ctx.value().locations;
locations.forEach((location, index) => {
const city = location.city.valueOf();
const country = location.country.valueOf();
if (!city || !country) return;
locations.forEach((otherLocation, otherIndex) => {
if (index !== otherIndex) {
if (
city === otherLocation.city.valueOf() &&
country === otherLocation.country.valueOf()
) {
errors.push({
kind: ‘duplicate_location’,
field: ctx.field.locations[index].city,
message: `Duplicate location: ${city}, ${country}`,
});
}
}
});
});
return errors.length > 0 ? errors : null;
});
});
The integration between Signal Forms and standard schema libraries like Zod demonstrates Angular’s commitment to interoperability and developer choice, allowing you to build robust forms using the tools you prefer.
Schema Functions: Building Reusable Form Logic - Source Code
A schema is a reusable validation blueprint that encapsulates all the rules, logic, and constraints for a particular data structure. Think of it as a template that can be applied to any compatible field in your form tree.
Inline Schema vs. Schema Function
// Inline: Validation defined directly in the form
const myForm = form(signal(data), (path) => {
required(path.city);
minLength(path.city, 2);
required(path.country);
minLength(path.country, 2);
});
// Schema Function: Reusable validation logic
const locationSchema = schema<WeatherLocation>((path) => {
required(path.city);
minLength(path.city, 2);
required(path.country);
minLength(path.country, 2);
});
// Apply the schema anywhere
const myForm = form(signal(data), (path) => {
apply(path, locationSchema);
});
Creating Basic Schemas
Let’s build schemas for our weather application, starting simple and progressing to complex patterns.
Simple Field Schema
import { schema, required, minLength, maxLength } from ‘@angular/forms/signals’;
// Schema for a single city name
const cityNameSchema = schema<string>((path) => {
required(path, { message: ‘City is required’ });
minLength(path, 2, { message: ‘City must be at least 2 characters’ });
maxLength(path, 50, { message: ‘City name is too long’ });
});
// Apply to a field
form(signal({ city: ‘’ }), (path) => {
apply(path.city, cityNameSchema);
});
Object Schema
type WeatherLocation = {
city: string;
country: string;
};
const locationSchema = schema<WeatherLocation>((path) => {
// Validate city field
required(path.city, { message: ‘City is required’ });
minLength(path.city, 2, { message: ‘City must be at least 2 characters’ });
maxLength(path.city, 50, { message: ‘City name is too long’ });
// Validate country field
required(path.country, { message: ‘Country is required’ });
minLength(path.country, 2, { message: ‘Country must be at least 2 characters’ });
maxLength(path.country, 50, { message: ‘Country name is too long’ });
});
// Use it
form(signal<WeatherLocation>({ city: ‘’, country: ‘’ }), (path) => {
apply(path, locationSchema);
});
Schema Composition: Building Complex Schemas from Simple Ones
One of the most powerful features of schemas is composition - building complex validation from smaller, reusable pieces.
// weather-form.schemas.ts
import {
schema,
required,
minLength,
maxLength,
validate,
customError,
apply,
applyEach
} from ‘@angular/forms/signals’;
// 1. Atomic schemas - smallest reusable units
const cityNameSchema = schema<string>((path) => {
required(path, { message: ‘City is required’ });
minLength(path, 2, { message: ‘City must be at least 2 characters’ });
maxLength(path, 50, { message: ‘City name is too long’ });
});
const countryNameSchema = schema<string>((path) => {
required(path, { message: ‘Country is required’ });
minLength(path, 2, { message: ‘Country must be at least 2 characters’ });
maxLength(path, 50, { message: ‘Country name is too long’ });
});
// 2. Composite schema - combines atomic schemas
export const locationSchema = schema<WeatherLocation>((path) => {
apply(path.city, cityNameSchema);
apply(path.country, countryNameSchema);
});
// 3. Array schema with composite validation
export const locationsArraySchema = schema<WeatherLocation[]>((path) => {
// Validate array itself
validate(path, (ctx) => {
if (ctx.value().length === 0) {
return customError({
kind: ‘empty_array’,
message: ‘At least one location is required’
});
}
if (ctx.value().length > 5) {
return customError({
kind: ‘too_many’,
message: ‘Maximum 5 locations allowed’
});
}
return null;
});
// Apply location schema to each item
applyEach(path, locationSchema);
});
// 4. Date validation schema
export const futureDateSchema = schema<string>((path) => {
required(path, { message: ‘Date is required’ });
validate(path, (ctx) => {
const selectedDate = new Date(ctx.value());
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate < today) {
return customError({
kind: ‘past_date’,
message: ‘Date cannot be in the past’
});
}
const maxDate = new Date();
maxDate.setDate(maxDate.getDate() + 14);
if (selectedDate > maxDate) {
return customError({
kind: ‘far_future’,
message: ‘Weather forecasts only available for the next 14 days’
});
}
return null;
});
});
// 5. Temperature unit schema
export const temperatureUnitSchema = schema<TemperatureUnit>((path) => {
required(path, { message: ‘Temperature unit is required’ });
validate(path, (ctx) => {
const value = ctx.value();
if (value !== ‘celsius’ && value !== ‘fahrenheit’) {
return customError({
kind: ‘invalid_unit’,
message: ‘Temperature unit must be celsius or fahrenheit’
});
}
return null;
});
});
// 6. Complete form schema - orchestrates all schemas
export const weatherFormSchema = schema<WeatherFormData>((path) => {
apply(path.date, futureDateSchema);
apply(path.locations, locationsArraySchema);
apply(path.temperatureUnit, temperatureUnitSchema);
});
Using Schemas in Your Component
Approach 1: Apply Complete Schema
@Component({
selector: ‘app-weather-chatbot’,
// ...
})
export class WeatherChatbotComponent {
private readonly _weatherData = signal<WeatherFormData>({
date: new Date().toISOString().split(’T’)[0],
locations: [{ city: ‘’, country: ‘’ }],
temperatureUnit: ‘celsius’,
});
protected readonly weatherForm = form(this._weatherData, (path) => {
// Single line applies all validation
apply(path, weatherFormSchema);
// Add custom validators on top
applyEach(path.locations, (location) => {
validateAsync(location.city, {
// ... async validation
});
});
validateTree(path, (ctx) => {
// ... duplicate detection
});
});
}
Approach 2: Apply Partial Schemas
protected readonly weatherForm = form(this._weatherData, (path) => {
// Pick and choose which schemas to apply
apply(path.date, futureDateSchema);
apply(path.locations, locationsArraySchema);
apply(path.temperatureUnit, temperatureUnitSchema);
// Add inline validation for specific needs
validate(path, (ctx) => {
// Custom form-level validation
});
});
Approach 3: Conditional Schema Application
protected readonly weatherForm = form(this._weatherData, (path) => {
apply(path.date, futureDateSchema);
// Apply different validation based on mode
if (this.isPremiumUser()) {
// Premium users can add more locations
apply(path.locations, premiumLocationsArraySchema);
} else {
apply(path.locations, locationsArraySchema);
}
apply(path.temperatureUnit, temperatureUnitSchema);
});
Advanced Schema Patterns
Parametric Schemas
Create schemas that accept configuration:
// Schema factory that accepts parameters
function createLocationLimitSchema(min: number, max: number) {
return schema<WeatherLocation[]>((path) => {
validate(path, (ctx) => {
const length = ctx.value().length;
if (length < min) {
return customError({
kind: ‘too_few’,
message: `At least ${min} location${min > 1 ? ‘s’ : ‘’} required`
});
}
if (length > max) {
return customError({
kind: ‘too_many’,
message: `Maximum ${max} locations allowed`
});
}
return null;
});
applyEach(path, locationSchema);
});
}
// Use with different limits
const freeUserForm = form(data, (path) => {
apply(path.locations, createLocationLimitSchema(1, 3));
});
const premiumUserForm = form(data, (path) => {
apply(path.locations, createLocationLimitSchema(1, 10));
});
Conditional Validation Schemas
type WeatherQuery = {
searchType: ‘current’ | ‘forecast’;
date?: string;
locations: WeatherLocation[];
};
const currentWeatherSchema = schema<WeatherQuery>((path) => {
apply(path.locations, locationsArraySchema);
// Date should be hidden/ignored for current weather
hidden(path.date);
});
const forecastWeatherSchema = schema<WeatherQuery>((path) => {
apply(path.locations, locationsArraySchema);
apply(path.date, futureDateSchema);
});
// Apply conditionally
protected readonly weatherForm = form(this._weatherData, (path) => {
applyWhenValue(
path,
(value): value is Extract<WeatherQuery, { searchType: ‘current’ }> =>
value.searchType === ‘current’,
currentWeatherSchema
);
applyWhenValue(
path,
(value): value is Extract<WeatherQuery, { searchType: ‘forecast’ }> =>
value.searchType === ‘forecast’,
forecastWeatherSchema
);
});
Schema vs. Inline: When to Use Each
Use Schemas When:
Validation logic is shared across multiple forms
Testing validation logic independently
Building a validation library for your organization
Complex nested structures benefit from composition
Team needs clear documentation of validation rules
Use Inline When:
Validation is unique to a single form
Quick prototyping or simple forms
Validation tightly coupled to component state
One-off forms that won’t be reused
Schemas transform Signal Forms from a validation tool into a scalable validation architecture, enabling teams to build maintainable, testable, and reusable form logic across their entire application.
Conclusion
Signal Forms is currently experimental in Angular 21.
Throughout this guide, we’ve transformed a real-world weather chatbot application, demonstrating how Signal Forms addresses pain points at every level:
Reduced Complexity: What once required FormBuilder, FormGroup, and manual subscription management now distills into a single form()
function bound to a signal. The verbose template logic with repeated weatherForm.get()
calls becomes clean, type-safe field navigation.
Enhanced Validation: From basic built-in validators to async API validation with validateAsync()
, cross-field logic with validateTree()
, and standard schema integration with Zod—Signal Forms provides a complete validation toolkit that scales from simple forms to complex, multi-step workflows.
Composable Architecture: The schema()
function enables building reusable validation blueprints that can be tested independently, shared across teams, and composed into sophisticated validation hierarchies. This modularity transforms validation from scattered logic into maintainable, documented architecture.
Performance by Default: Signals provide fine-grained reactivity without Observable overhead. OnPush change detection works naturally, and the framework only updates what changed. The result is forms that are both easier to write and faster to run.
The forms you’ll build with Signal Forms—once stable—will be simpler, faster, and more maintainable than ever before. The experimental phase is Angular’s invitation to help shape that future.
Thanks for reading so far 🙏
I’d love to hear your thoughts, so please leave a comment, clap, or follow. 👏
Spread the Angular enthusiasm! 💜
If you enjoyed it, share it with your community, tech friends, and anyone else! 🚀👥
Don’t forget to follow me and stay in the loop: 📱
Thanks for being part of this Angular adventure! 👋😁