DEV Community

Amos Isaila
Amos Isaila

Posted on • Originally published at codigotipado.com on

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).

Angular 21 Signal Forms API

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

Weather App Using Signal Forms in Angular

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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...”
/>
Enter fullscreen mode Exit fullscreen mode

Identified Pain Points

  1. Boilerplate Overload : FormBuilder, FormGroup, manual validation setup
  2. Type Safety Issues : weatherForm.value as WeatherFormData casting required
  3. Complex Error Handling : Nested conditionals for validation messages
  4. State Synchronization : Manual form value computed signals
  5. Verbose Template Logic : Repeated weatherForm.get() calls
  6. Mixed 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(model, schema?, options?)

Creates a Signal Form bound to a data model. Updating the FieldState (form fields) updates also the model.

Parameters:

  • model: WritableSignal - The data signal that serves as the source of truth
  • schema?: SchemaOrSchemaFn - Optional validation and logic rules
  • options?: FormOptions - Optional configuration (injector, name, adapter)

Returns: Field - 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
Enter fullscreen mode Exit fullscreen mode
  1. 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’ });
  }
);
Enter fullscreen mode Exit fullscreen mode

The path parameter:

  • Type: FieldPath
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

Characteristics:

  • Only created through applyEach
  • Represents a single element in an array
  • Has access to additional context (like index)
  1. 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);
Enter fullscreen mode Exit fullscreen mode
  1. 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
  }
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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) }
);
Enter fullscreen mode Exit fullscreen mode

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() 
});
Enter fullscreen mode Exit fullscreen mode

schema(fn: SchemaFn)

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 - A function that defines validation and logic rules ( non-reactive function)

Returns: Schema - A reusable schema object

Example:

const addressSchema = schema<Address>((path) => {
  required(path.street);
  required(path.city);
  minLength(path.zipCode, 5);
});
Enter fullscreen mode Exit fullscreen mode

Field Types

Field

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() returns FieldState
  • Navigate as object: myForm.name accesses nested fields
  • Arrays are iterable: for (let item of myForm.items)

FieldState

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>;
}
Enter fullscreen mode Exit fullscreen mode

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” />
Enter fullscreen mode Exit fullscreen mode

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) 
});
Enter fullscreen mode Exit fullscreen mode

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’ });
Enter fullscreen mode Exit fullscreen mode

Also sets: MIN_LENGTH aggregate property

maxLength(path, length, config?)

Validates maximum string/array length.

maxLength(path.bio, 500);
Enter fullscreen mode Exit fullscreen mode

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’ });
Enter fullscreen mode Exit fullscreen mode

Also sets: MIN aggregate property

max(path, value, config?)

Validates maximum numeric value.

max(path.quantity, 100);
Enter fullscreen mode Exit fullscreen mode

Also sets: MAX aggregate property

pattern(path, regex, config?)

Validates against a regular expression.

pattern(path.phone, /^\d{3}-\d{3}-\d{4}$/);
Enter fullscreen mode Exit fullscreen mode

Also sets: PATTERN aggregate property

email(path, config?)

Validates email format.

email(path.email);
email(path.email, { message: ‘Invalid email address’ });
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Validator return types:

  • null | undefined | void - No error
  • ValidationError - Single error
  • ValidationError[] - 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;
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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);
  }
);
Enter fullscreen mode Exit fullscreen mode

Field State Logic

disabled(path, logic?)

Makes a field disabled.

disabled(path.endDate, (ctx) => !ctx.valueOf(path.hasEndDate));
Enter fullscreen mode Exit fullscreen mode

readonly(path, logic?)

Makes a field readonly.

readonly(path.id); // Always readonly
readonly(path.price, (ctx) => ctx.valueOf(path.isLocked));
Enter fullscreen mode Exit fullscreen mode

hidden(path, logic)

Hides a field from display and validation.

hidden(path.optionalDetails, (ctx) => !ctx.valueOf(path.showDetails));
Enter fullscreen mode Exit fullscreen mode

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
    }];
  }
});
Enter fullscreen mode Exit fullscreen mode

Template usage:

<form (ngSubmit)=”onSubmit()”>
  <!-- form fields -->
</form>
Enter fullscreen mode Exit fullscreen mode

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'
})
Enter fullscreen mode Exit fullscreen mode

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': /* ... */
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom Controls (no longer need ControlValueAccessor)

To create custom form controls, implement FormValueControl:

@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
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<app-custom-input [control]=”myForm.field” />
Enter fullscreen mode Exit fullscreen mode

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) }}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

Dynamic Signal Form Arrays

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;
};
Enter fullscreen mode Exit fullscreen mode

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}.`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Real-World Weather API Example — Source Code

Angular Signal Forms: validateAsync

// 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;
        },
      });

Enter fullscreen mode Exit fullscreen mode

Async Validator Behavior

  • Only runs after all sync validators pass
  • Field shows pending() === true while async validation runs
  • Updates 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>
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Type Inference

// TypeScript types automatically generated from schema
type WeatherFormData = z.infer<typeof weatherFormSchema>;
Enter fullscreen mode Exit fullscreen mode

Setting Up Zod Schemas

First, install Zod:

npm install zod
Enter fullscreen mode Exit fullscreen mode

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' })
  })
});
Enter fullscreen mode Exit fullscreen mode

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;
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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));
});
Enter fullscreen mode Exit fullscreen mode

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
  );
});
Enter fullscreen mode Exit fullscreen mode

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 like to have your feedback so please leave a comment , clap or follow. 👏

Spread the Angular love! 💜

If you really liked it, share it among your community, tech bros and whoever you want! 🚀👥

Don’t forget to follow me and stay updated: 📱

Thanks for being part of this Angular journey! 👋😁

Originally published at https://www.codigotipado.com.

Top comments (0)