angular-forms
Angular v21以上で新しいSignal Forms APIを使用して、シグナルベースのフォームを構築できます。自動的な双方向バインディング、スキーマベースの検証、フィールド状態管理、動的フォーム機能を備えたフォーム作成に使用します。フォーム実装の追加、検証の実装、マルチステップフォームの作成、条件付きフィールドを含むフォーム構築の際に活用できます。Signal Formsは実験的機能ですが、新規のAngularプロジェクトに推奨されています。
description の原文を見る
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.
SKILL.md 本文
Angular Signal Forms
Angular の Signal Forms API を使用して、タイプセーフでリアクティブなフォームを構築します。Signal Forms は自動的な双方向バインディング、スキーマベースの検証、リアクティブなフィールド状態を提供します。
注釈: Signal Forms は Angular v21 では実験的機能です。本番環境で安定性が必要なアプリケーションについては、references/form-patterns.md の Reactive Forms パターンを参照してください。
基本的なセットアップ
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 LoginComponent {
// フォームモデル - 書き込み可能なシグナル
loginModel = signal<LoginData>({
email: '',
password: '',
});
// 検証スキーマを使用してフォームを作成
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);
}
}
}
フォームモデル
フォームモデルは書き込み可能なシグナルであり、単一の情報源として機能します。
// 型安全性のためのインターフェースを定義
interface UserProfile {
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
};
}
// 初期値を使用してモデルシグナルを作成
const userModel = signal<UserProfile>({
name: '',
email: '',
age: null,
preferences: {
newsletter: false,
theme: 'light',
},
});
// モデルからフォームを作成
const userForm = form(userModel);
// ドット記法でネストされたフィールドにアクセス
userForm.name // FieldTree<string>
userForm.preferences.theme // FieldTree<'light' | 'dark'>
値の読み込み
// モデル全体を読み込み
const data = this.userModel();
// フィールド状態を使用してフィールド値を読み込み
const name = this.userForm.name().value();
const theme = this.userForm.preferences.theme().value();
値の更新
// モデル全体を置き換え
this.userModel.set({
name: 'Alice',
email: 'alice@example.com',
age: 30,
preferences: { newsletter: true, theme: 'dark' },
});
// 単一フィールドを更新
this.userForm.name().value.set('Bob');
this.userForm.age().value.update(age => (age ?? 0) + 1);
フィールド状態
各フィールドは検証、インタラクション、および利用可能性のためのリアクティブシグナルを提供します。
const emailField = this.form.email();
// 検証状態
emailField.valid() // すべての検証に合格した場合は true
emailField.invalid() // 検証エラーがある場合は true
emailField.errors() // エラーオブジェクトの配列
emailField.pending() // 非同期検証が進行中の場合は true
// インタラクション状態
emailField.touched() // フォーカスとブラーの後は true
emailField.dirty() // ユーザー変更後は true
// 利用可能性状態
emailField.disabled() // フィールドが無効な場合は true
emailField.hidden() // フィールドを非表示にする場合は true
emailField.readonly() // フィールドが読み込み専用の場合は true
// 値
emailField.value() // 現在のフィールド値 (シグナル)
フォームレベルの状態
フォーム自体も集計された状態を持つフィールドです。
// すべてのインタラクティブフィールドが有効な場合、フォームは有効です
this.form().valid()
// いずれかのフィールドがタッチされた場合、フォームはタッチされます
this.form().touched()
// いずれかのフィールドが変更された場合、フォームはダーティーになります
this.form().dirty()
検証
組み込みバリデーター
import {
form, required, email, min, max,
minLength, maxLength, pattern
} from '@angular/forms/signals';
const userForm = form(this.userModel, (schemaPath) => {
// 必須フィールド
required(schemaPath.name, { message: 'Name is required' });
// メールアドレス形式
email(schemaPath.email, { message: 'Invalid email' });
// 数値の範囲
min(schemaPath.age, 18, { message: 'Must be 18+' });
max(schemaPath.age, 120, { message: 'Invalid age' });
// 文字列/配列の長さ
minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
// 正規表現パターン
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: 'Format: 555-123-4567',
});
});
条件付き検証
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
カスタムバリデーター
import { validate } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
// カスタム検証ロジック
validate(schemaPath.username, ({ value }) => {
if (value().includes(' ')) {
return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
}
return null;
});
});
フィールド間の検証
const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password);
required(schemaPath.confirmPassword);
// フィールドを比較
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: 'mismatch', message: 'Passwords do not match' };
}
return null;
});
});
非同期検証
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',
}),
});
});
条件付きフィールド
非表示フィールド
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" />
}
無効フィールド
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
読み込み専用フィールド
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // 常に読み込み専用
});
フォーム送信
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 LoginComponent {
model = signal({ email: '', password: '' });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
// submit() はすべてのフィールドをタッチ済みにマークし、有効な場合はコールバックを実行します
submit(this.form, async () => {
await this.authService.login(this.model());
});
}
}
配列と動的フィールド
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 OrderComponent {
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),
}));
}
}
エラーを表示
<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>
}
状態に基づくスタイル設定
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
フォームをリセット
async onSubmit() {
if (!this.form().valid()) return;
await this.api.submit(this.model());
// インタラクション状態をクリア
this.form().reset();
// 値をクリア
this.model.set({ email: '', password: '' });
}
本番環境で安定した Reactive Forms パターンについては、references/form-patterns.md を参照してください。
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- ROU-Technology
- ライセンス
- MIT
- 最終更新
- 2026/1/26
Source: https://github.com/ROU-Technology/ng-utils / ライセンス: MIT