angular-forms

Signal-based reactive forms for Angular v21+ with automatic two-way binding and schema-based validation. Provides type-safe form creation using writable signals as the single source of truth, with automatic field state management for validation, interaction, and availability Includes built-in validators (required, email, min, max, pattern) plus custom, cross-field, and async HTTP validation with conditional logic Supports dynamic arrays, nested objects, hidden/disabled/readonly fields, and form-level state aggregation across all interactive fields Experimental API recommended for new projects; production apps should reference Reactive Forms patterns for stability

INSTALLATION
npx skills add https://github.com/analogjs/angular-skills --skill angular-forms
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2c

@Component({

selector: 'app-login',

imports: [FormField],

template:

<form (submit)="onSubmit($event)">

Email

<input type="email" [formField]="loginForm.email" />

@if (loginForm.email().touched() &#x26;&#x26; loginForm.email().invalid()) {

{{ loginForm.email().errors()[0].message }}

}

<label>

    Password

    <input type="password" [formField]="loginForm.password" />

  </label>

  @if (loginForm.password().touched() &#x26;&#x26; 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({

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 [formField]="item.product" placeholder="Product" />

<input [formField]="item.quantity" type="number" />

<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() &#x26;&#x26; 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() &#x26;&#x26; form.email().invalid()"

[class.is-valid]="form.email().touched() &#x26;&#x26; 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: '' });

}

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card