Files
NGRX-Playground/.agents/skills/angular-forms/SKILL.md

12 KiB

name, description
name description
angular-forms Build signal-based forms in Angular v21+ using the new Signal Forms API. Use for form creation with automatic two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Signal Forms are experimental but recommended for new Angular projects. Don't use for template-driven forms without signals or third-party form libraries like Formly or ngx-formly.

Angular Signal Forms

Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state.

Note: Signal Forms are experimental in Angular v21. For production apps requiring stability, see references/form-patterns.md for Reactive Forms patterns.

Basic Setup

import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';

interface LoginData {
    email: string;
    password: string;
}

@Component({
    selector: 'app-login',
    imports: [FormField],
    template: `
        <form (submit)="onSubmit($event)">
            <label>
                Email
                <input
                    type="email"
                    [formField]="loginForm.email" />
            </label>
            @if (loginForm.email().touched() && loginForm.email().invalid()) {
                <p class="error">{{ loginForm.email().errors()[0].message }}</p>
            }

            <label>
                Password
                <input
                    type="password"
                    [formField]="loginForm.password" />
            </label>
            @if (loginForm.password().touched() && loginForm.password().invalid()) {
                <p class="error">{{ loginForm.password().errors()[0].message }}</p>
            }

            <button
                type="submit"
                [disabled]="loginForm().invalid()">
                Login
            </button>
        </form>
    `,
})
export class Login {
    // Form model - a writable signal
    loginModel = signal<LoginData>({
        email: '',
        password: '',
    });

    // Create form with validation schema
    loginForm = form(this.loginModel, (schemaPath) => {
        required(schemaPath.email, { message: 'Email is required' });
        email(schemaPath.email, { message: 'Enter a valid email address' });
        required(schemaPath.password, { message: 'Password is required' });
    });

    onSubmit(event: Event) {
        event.preventDefault();
        if (this.loginForm().valid()) {
            const credentials = this.loginModel();
            console.log('Submitting:', credentials);
        }
    }
}

Form Models

Form models are writable signals that serve as the single source of truth:

// Define interface for type safety
interface UserProfile {
    name: string;
    email: string;
    age: number | null;
    preferences: {
        newsletter: boolean;
        theme: 'light' | 'dark';
    };
}

// Create model signal with initial values
const userModel = signal<UserProfile>({
    name: '',
    email: '',
    age: null,
    preferences: {
        newsletter: false,
        theme: 'light',
    },
});

// Create form from model
const userForm = form(userModel);

// Access nested fields via dot notation
userForm.name; // FieldTree<string>
userForm.preferences.theme; // FieldTree<'light' | 'dark'>

Reading Values

// Read entire model
const data = this.userModel();

// Read field value via field state
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();

Updating Values

// Replace entire model
this.userModel.set({
    name: 'Alice',
    email: 'alice@example.com',
    age: 30,
    preferences: { newsletter: true, theme: 'dark' },
});

// Update single field
this.userForm.name().value.set('Bob');
this.userForm.age().value.update((age) => (age ?? 0) + 1);

Field State

Each field provides reactive signals for validation, interaction, and availability:

const emailField = this.form.email();

// Validation state
emailField.valid(); // true if passes all validation
emailField.invalid(); // true if has validation errors
emailField.errors(); // array of error objects
emailField.pending(); // true if async validation in progress

// Interaction state
emailField.touched(); // true after focus + blur
emailField.dirty(); // true after user modification

// Availability state
emailField.disabled(); // true if field is disabled
emailField.hidden(); // true if field should be hidden
emailField.readonly(); // true if field is readonly

// Value
emailField.value(); // current field value (signal)

Form-Level State

The form itself is also a field with aggregated state:

// Form is valid when all interactive fields are valid
this.form().valid();

// Form is touched when any field is touched
this.form().touched();

// Form is dirty when any field is modified
this.form().dirty();

Validation

Built-in Validators

import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';

const userForm = form(this.userModel, (schemaPath) => {
    // Required field
    required(schemaPath.name, { message: 'Name is required' });

    // Email format
    email(schemaPath.email, { message: 'Invalid email' });

    // Numeric range
    min(schemaPath.age, 18, { message: 'Must be 18+' });
    max(schemaPath.age, 120, { message: 'Invalid age' });

    // String/array length
    minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
    maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });

    // Regex pattern
    pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
        message: 'Format: 555-123-4567',
    });
});

Conditional Validation

const orderForm = form(this.orderModel, (schemaPath) => {
    required(schemaPath.promoCode, {
        message: 'Promo code required for discounts',
        when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
    });
});

Custom Validators

import { validate } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => {
    // Custom validation logic
    validate(schemaPath.username, ({ value }) => {
        if (value().includes(' ')) {
            return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
        }
        return null;
    });
});

Cross-Field Validation

const passwordForm = form(this.passwordModel, (schemaPath) => {
    required(schemaPath.password);
    required(schemaPath.confirmPassword);

    // Compare fields
    validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
        if (value() !== valueOf(schemaPath.password)) {
            return { kind: 'mismatch', message: 'Passwords do not match' };
        }
        return null;
    });
});

Async Validation

import { validateHttp } from '@angular/forms/signals';

const signupForm = form(this.signupModel, (schemaPath) => {
    validateHttp(schemaPath.username, {
        request: ({ value }) => `/api/check-username?u=${value()}`,
        onSuccess: (response: { taken: boolean }) => {
            if (response.taken) {
                return { kind: 'taken', message: 'Username already taken' };
            }
            return null;
        },
        onError: () => ({
            kind: 'networkError',
            message: 'Could not verify username',
        }),
    });
});

Conditional Fields

Hidden Fields

import { hidden } from '@angular/forms/signals';

const profileForm = form(this.profileModel, (schemaPath) => {
    hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
@if (!profileForm.publicUrl().hidden()) {
<input [formField]="profileForm.publicUrl" />
}

Disabled Fields

import { disabled } from '@angular/forms/signals';

const orderForm = form(this.orderModel, (schemaPath) => {
    disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});

Readonly Fields

import { readonly } from '@angular/forms/signals';

const accountForm = form(this.accountModel, (schemaPath) => {
    readonly(schemaPath.username); // Always readonly
});

Form Submission

import { submit } from '@angular/forms/signals';

@Component({
    template: `
        <form (submit)="onSubmit($event)">
            <input [formField]="form.email" />
            <input [formField]="form.password" />
            <button
                type="submit"
                [disabled]="form().invalid()">
                Submit
            </button>
        </form>
    `,
})
export class Login {
    model = signal({ email: '', password: '' });
    form = form(this.model, (schemaPath) => {
        required(schemaPath.email);
        required(schemaPath.password);
    });

    onSubmit(event: Event) {
        event.preventDefault();

        // submit() marks all fields touched and runs callback if valid
        submit(this.form, async () => {
            await this.authService.login(this.model());
        });
    }
}

Arrays and Dynamic Fields

interface Order {
    items: Array<{ product: string; quantity: number }>;
}

@Component({
    template: `
        @for (item of orderForm.items; track $index; let i = $index) {
            <div>
                <input
                    placeholder="Product"
                    [formField]="item.product" />
                <input
                    type="number"
                    [formField]="item.quantity" />
                <button
                    type="button"
                    (click)="removeItem(i)">
                    Remove
                </button>
            </div>
        }
        <button
            type="button"
            (click)="addItem()">
            Add Item
        </button>
    `,
})
export class Order {
    orderModel = signal<Order>({
        items: [{ product: '', quantity: 1 }],
    });

    orderForm = form(this.orderModel, (schemaPath) => {
        applyEach(schemaPath.items, (item) => {
            required(item.product, { message: 'Product required' });
            min(item.quantity, 1, { message: 'Min quantity is 1' });
        });
    });

    addItem() {
        this.orderModel.update((m) => ({
            ...m,
            items: [...m.items, { product: '', quantity: 1 }],
        }));
    }

    removeItem(index: number) {
        this.orderModel.update((m) => ({
            ...m,
            items: m.items.filter((_, i) => i !== index),
        }));
    }
}

Displaying Errors

<input [formField]="form.email" />

@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
    @for (error of form.email().errors(); track error) {
    <li>{{ error.message }}</li>
    }
</ul>
} @if (form.email().pending()) {
<span>Validating...</span>
}

Styling Based on State

<input
    [formField]="form.email"
    [class.is-invalid]="form.email().touched() && form.email().invalid()"
    [class.is-valid]="form.email().touched() && form.email().valid()" />

Reset Form

async onSubmit() {
  if (!this.form().valid()) return;

  await this.api.submit(this.model());

  // Clear interaction state
  this.form().reset();

  // Clear values
  this.model.set({ email: '', password: '' });
}

For Reactive Forms patterns (production-stable), see references/form-patterns.md.