NOTE: このガイドは、Signal Formsの基本に精通していることを前提としています。
ブラウザの組み込みフォームコントロール(input、select、textareaなど)は一般的なケースを扱いますが、アプリケーションではしばしば特殊な入力が必要になります。カレンダーUIを持つ日付ピッカー、書式設定ツールバーを持つリッチテキストエディタ、オートコンプリート機能を持つタグセレクターなどは、すべてカスタム実装が必要です。
シグナルフォームは、特定のインターフェースを実装するあらゆるコンポーネントと連携して動作します。コントロールインターフェースは、コンポーネントがフォームシステムと通信するためのプロパティとシグナルを定義します。コンポーネントがこれらのインターフェースのいずれかを実装すると、[formField]ディレクティブが自動的にコントロールをフォームの状態、バリデーション、データバインディングに接続します。
HELPFUL: Custom Signal Form Controls can be used with Signal, Reactive and Template-Driven Forms without any extra compatibility code.
基本的なカスタムコントロールの作成
最小限の実装から始めて、必要に応じて機能を追加していきましょう。
最小限の入力コントロール
基本的なカスタム入力は、FormValueControlインターフェースを実装し、必須のvalueモデルシグナルを定義するだけで済みます。
import {Component, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
@Component({
selector: 'app-basic-input',
template: `
<div class="basic-input">
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
placeholder="Enter text..."
/>
</div>
`,
})
export class BasicInput implements FormValueControl<string> {
/** The current input value */
value = model('');
}
最小限のチェックボックスコントロール
チェックボックス形式のコントロールには、次の2つが必要です:
FormFieldディレクティブがフォームコントロールとして認識できるように、FormCheckboxControlインターフェースを実装するcheckedモデルシグナルを提供する
import {Component, model, ChangeDetectionStrategy} from '@angular/core';
import {FormCheckboxControl} from '@angular/forms/signals';
@Component({
selector: 'app-basic-toggle',
template: `
<button type="button" [class.active]="checked()" (click)="toggle()">
<span class="toggle-slider"></span>
</button>
`,
})
export class BasicToggle implements FormCheckboxControl {
/** Whether the toggle is checked */
checked = model<boolean>(false);
toggle() {
this.checked.update((val) => !val);
}
}
カスタムコントロールの使用
コントロールを作成したら、FormFieldディレクティブを追加することで、組み込みの入力を使用する場所ならどこでも使用できます:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {BasicInput} from './basic-input';
import {BasicToggle} from './basic-toggle';
@Component({
imports: [FormField, BasicInput, BasicToggle],
template: `
<form novalidate>
<label>
Email
<app-basic-input [formField]="registrationForm.email" />
</label>
<label>
Accept terms
<app-basic-toggle [formField]="registrationForm.acceptTerms" />
</label>
<button type="submit" [disabled]="registrationForm().invalid()">Register</button>
</form>
`,
})
export class Registration {
registrationModel = signal({
email: '',
acceptTerms: false,
});
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
required(schemaPath.acceptTerms, {message: 'You must accept the terms'});
});
}
NOTE: スキーマのコールバックパラメータ(この例ではschemaPath)は、フォーム内のすべてのフィールドへのパスを提供するSchemaPathTreeオブジェクトです。このパラメータには好きな名前を付けることができます。
[formField]ディレクティブは、カスタムコントロールと組み込みの入力で同じように動作します。シグナルフォームはそれらを同じように扱います - バリデーションの実行、状態の更新、データバインディングが自動的に機能します。
コントロールインターフェースの理解
カスタムコントロールの動作を確認したところで、それらがシグナルフォームとどのように統合されるかを見ていきましょう。
コントロールインターフェース
作成したBasicInputとBasicToggleコンポーネントは、シグナルフォームにそれらとの対話方法を伝える特定のコントロールインターフェースを実装しています。
FormValueControl
FormValueControlは、テキスト入力、数値入力、日付ピッカー、セレクトドロップダウンなど、単一の値を編集するほとんどの入力タイプのためのインターフェースです。コンポーネントがこのインターフェースを実装する場合:
- 必須プロパティ: コンポーネントは
valueモデルシグナルを提供する必要があります - FormFieldディレクティブの役割: フォームフィールドの値をコントロールの
valueシグナルにバインドします
IMPORTANT: FormValueControlを実装するコントロールはcheckedプロパティを持ってはいけません
FormCheckboxControl
FormCheckboxControlは、トグル、スイッチなど、ブール値のオン/オフ状態を表すチェックボックスのようなコントロールのためのインターフェースです。コンポーネントがこのインターフェースを実装する場合:
- 必須プロパティ: コンポーネントは
checkedモデルシグナルを提供する必要があります - Fieldディレクティブの役割: フォームフィールドの値をコントロールの
checkedシグナルにバインドします
IMPORTANT: FormCheckboxControlを実装するコントロールはvalueプロパティを持ってはいけません
オプションの状態プロパティ
FormValueControlとFormCheckboxControlはどちらもFormUiControlを拡張します。これはフォームの状態と統合するためのオプションのプロパティを提供するベースインターフェースです。
すべてのプロパティはオプションです。コントロールが必要とするものだけを実装してください。
インタラクションの状態
ユーザーがコントロールを操作したときを追跡します:
| プロパティ | 目的 |
|---|---|
touched |
ユーザーがフィールドを操作したかどうか |
dirty |
値が初期状態と異なるかどうか |
バリデーションの状態
ユーザーにバリデーションのフィードバックを表示します:
| プロパティ | 目的 |
|---|---|
errors |
現在のバリデーションエラーの配列 |
valid |
フィールドが有効かどうか |
invalid |
フィールドにバリデーションエラーがあるかどうか |
pending |
非同期バリデーションが進行中かどうか |
可用性の状態
ユーザーがフィールドを操作できるかどうかを制御します:
| プロパティ | 目的 |
|---|---|
disabled |
フィールドが無効かどうか |
disabledReasons |
フィールドが無効になっている理由 |
readonly |
フィールドが読み取り専用(表示されるが編集不可)かどうか |
hidden |
フィールドがビューから隠されているかどうか |
NOTE: disabledReasonsはDisabledReasonオブジェクトの配列です。各オブジェクトはfieldプロパティ(フィールドツリーへの参照)とオプションのmessageプロパティを持ちます。メッセージにはreason.messageを介してアクセスします。
バリデーション制約
フォームからバリデーション制約の値を受け取ります:
| プロパティ | 目的 |
|---|---|
required |
フィールドが必須かどうか |
min |
最小数値(制約がない場合はundefined) |
max |
最大数値(制約がない場合はundefined) |
minLength |
最小の文字列長(制約がない場合はundefined) |
maxLength |
最大の文字列長(制約がない場合はundefined) |
pattern |
一致させる正規表現パターンの配列 |
フィールドのメタデータ
| プロパティ | 目的 |
|---|---|
name |
フィールドのname属性(フォームやアプリケーション全体で一意) |
以下の「状態シグナルの追加」セクションでは、これらのプロパティをコントロールに実装する方法を示します。
FormFieldディレクティブの仕組み
[field]ディレクティブは、コントロールがどのインターフェースを実装しているかを検出し、適切なシグナルを自動的にバインドします:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required} from '@angular/forms/signals';
import {CustomInput} from './custom-input';
import {CustomToggle} from './custom-toggle';
@Component({
selector: 'app-my-form',
imports: [FormField, CustomInput, CustomToggle],
template: `
<form novalidate>
<app-custom-input [formField]="userForm.username" />
<app-custom-toggle [formField]="userForm.subscribe" />
</form>
`,
})
export class MyForm {
formModel = signal({
username: '',
subscribe: false,
});
userForm = form(this.formModel, (schemaPath) => {
required(schemaPath.username, {message: 'Username is required'});
});
}
TIP: フォームモデルの作成と管理に関する完全な情報については、フォームモデルガイドを参照してください。
[formField]="userForm.username"をバインドすると、FormFieldディレクティブは次のようになります:
- コントロールが
FormValueControlを実装していることを検出します - 内部で
userForm.username().value()にアクセスし、それをコントロールのvalueモデルシグナルにバインドします - フォームの状態シグナル(
disabled()、errors()など)をコントロールのオプションの入力シグナルにバインドします - 更新はシグナルのリアクティビティを通じて自動的に行われます
状態シグナルの追加
上記の最小限のコントロールは機能しますが、フォームの状態には応答しません。オプションの入力シグナルを追加して、コントロールが無効状態に反応したり、バリデーションエラーを表示したり、ユーザーインタラクションを追跡したりできるようにできます。
以下は、一般的な状態プロパティを実装する包括的な例です:
import {Component, model, input, output, ChangeDetectionStrategy} from '@angular/core';
import {
FormValueControl,
WithOptionalFieldTree,
ValidationError,
DisabledReason,
} from '@angular/forms/signals';
@Component({
selector: 'app-stateful-input',
template: `
@if (!hidden()) {
<div class="input-container">
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
[disabled]="disabled()"
[readonly]="readonly()"
[class.invalid]="invalid()"
[attr.aria-invalid]="invalid()"
(blur)="touch.emit()"
/>
@if (invalid()) {
<div class="error-messages" role="alert">
@for (error of errors(); track error) {
<span class="error">{{ error.message }}</span>
}
</div>
}
@if (disabled() && disabledReasons().length > 0) {
<div class="disabled-reasons">
@for (reason of disabledReasons(); track reason) {
<span>{{ reason.message }}</span>
}
</div>
}
</div>
}
`,
})
export class StatefulInput implements FormValueControl<string> {
// Required
value = model<string>('');
// Writable interaction state - control updates these
touched = input<boolean>(false);
touch = output<void>();
// Read-only state - form system manages these
disabled = input<boolean>(false);
disabledReasons = input<readonly DisabledReason[]>([]);
readonly = input<boolean>(false);
hidden = input<boolean>(false);
invalid = input<boolean>(false);
errors = input<readonly WithOptionalFieldTree<ValidationError>[]>([]);
}
その結果、バリデーションと状態管理を備えたコントロールを使用できます:
import {Component, signal, ChangeDetectionStrategy} from '@angular/core';
import {form, FormField, required, email} from '@angular/forms/signals';
import {StatefulInput} from './stateful-input';
@Component({
imports: [FormField, StatefulInput],
template: `
<form novalidate>
<label>
Email
<app-stateful-input [formField]="loginForm.email" />
</label>
</form>
`,
})
export class Login {
loginModel = signal({email: ''});
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, {message: 'Email is required'});
email(schemaPath.email, {message: 'Enter a valid email address'});
});
}
ユーザーが無効なメールアドレスを入力すると、FormFieldディレクティブが自動的にinvalid()とerrors()を更新します。あなたのコントロールは、そのバリデーションフィードバックを表示できます。
Working with debounce('blur')
The debounce('blur') rule delays updates from the UI to the form model until the field is blurred, instead of applying them on every keystroke. Built-in controls report a blur to the form automatically. A custom control only participates if it emits its touch output in response to the native blur event:
import {Component, model, output} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
@Component({
selector: 'app-custom-input',
template: `
<input
type="text"
[value]="value()"
(input)="value.set($event.target.value)"
(blur)="touch.emit()"
/>
`,
})
export class CustomInput implements FormValueControl<string> {
value = model('');
touch = output<void>();
}
With the touch output in place, debounce('blur') behaves the same for your control as it does for built-in inputs:
import {Component, signal} from '@angular/core';
import {debounce, form, FormField} from '@angular/forms/signals';
import {CustomInput} from './custom-input';
@Component({
selector: 'app-root',
imports: [CustomInput, FormField],
template: `<app-custom-input [formField]="userForm.name" />`,
})
export class App {
userModel = signal({name: ''});
userForm = form(this.userModel, (schemaPath) => {
debounce(schemaPath.name, 'blur');
});
}
IMPORTANT: Emit touch on blur (when focus leaves the control), not on focus. Without the touch output the field never registers as blurred, so debounce('blur') has no effect on your control.
値の変換
コントロールは、フォームモデルに格納されている値とは異なる形式で値を表示することがあります。例えば、日付ピッカーは「2024-01-15」と格納しながら「January 15, 2024」と表示したり、通貨入力は1234.56と格納しながら「$1,234.56」と表示したりします。
@angular/coreのlinkedSignal()を使用してモデルの値を表示用に変換し、入力イベントを処理してユーザー入力を格納形式にパースして戻します:
import {formatCurrency} from '@angular/common';
import {ChangeDetectionStrategy, Component, linkedSignal, model} from '@angular/core';
import {FormValueControl} from '@angular/forms/signals';
@Component({
selector: 'app-currency-input',
template: `
<input
type="text"
[value]="displayValue()"
(input)="displayValue.set($event.target.value)"
(blur)="updateModel()"
/>
`,
})
export class CurrencyInput implements FormValueControl<number> {
// 数値 (1234.56) を格納します
readonly value = model.required<number>();
// 表示値 (「1,234.56」) を格納します
readonly displayValue = linkedSignal(() => formatCurrency(this.value(), 'en', 'USD'));
// 表示値からモデルを更新します
updateModel() {
this.value.set(parseCurrency(this.displayValue()));
}
}
// 通貨文字列を数値に変換します (例: 「USD1,234.56」 -> 1234.56)
function parseCurrency(value: string): number {
return parseFloat(value.replace(/^[^\d-]+/, '').replace(/,/g, ''));
}
バリデーションの統合
コントロールはバリデーションの状態を表示しますが、バリデーションは実行しません。バリデーションはフォームスキーマで行われます。コントロールはFormFieldディレクティブからinvalid()とerrors()シグナルを受け取り、それらを表示します(上記のStatefulInputの例で示されているように)。
FormFieldディレクティブは、required、min、max、minLength、maxLength、patternのようなバリデーション制約の値も渡します。コントロールはこれらを使用してUIを強化できます:
export class NumberInput implements FormValueControl<number> {
value = model<number>(0);
// スキーマのバリデーションルールからの制約値
required = input<boolean>(false);
min = input<number | undefined>(undefined);
max = input<number | undefined>(undefined);
}
スキーマにmin()とmax()のバリデーションルールを追加すると、FormFieldディレクティブはこれらの値をコントロールに渡します。これらを使用して、HTML5属性を適用したり、テンプレートに制約のヒントを表示したりします。
IMPORTANT: コントロールにバリデーションロジックを実装しないでください。バリデーションルールはフォームスキーマで定義し、コントロールにはその結果を表示させるようにしてください:
// 悪い例:コントロール内でのバリデーション
export class BadControl implements FormValueControl<string> {
value = model<string>('');
isValid() {
return this.value().length >= 8;
} // これは行わないでください!
}
// 良い例:スキーマでバリデーションし、コントロールは結果を表示
accountForm = form(this.accountModel, (schemaPath) => {
minLength(schemaPath.password, 8, {message: 'Password must be at least 8 characters'});
});
Making controls reusable
A custom control often carries implicit expectations about validation. An email input needs required and email rules in every form that uses it. Instead of relying on each consumer to redeclare those rules, package a companion schema alongside the control and export both from the same module:
email-input.ts
import {schema, required, email} from '@angular/forms/signals';
export const emailFieldSchema = schema<string>((path) => {
required(path, {message: 'Email is required'});
email(path, {message: 'Enter a valid email address'});
});
A consumer imports the companion schema and composes it into their form with apply():
registration.ts
import {form, apply} from '@angular/forms/signals';
import {emailFieldSchema} from './email-input';
registrationForm = form(this.registrationModel, (path) => {
apply(path.email, emailFieldSchema);
});
apply() merges the companion schema's rules into the parent form at the specified path. The consumer can still add more rules to the same field because apply() composes with other rules rather than replacing them. See the Schemas guide for complete coverage of schema(), apply(), and conditional composition with applyWhen().
Design considerations
The consumer's model must initialize every field with a defined value. In Signal Forms, undefined signifies the absence of a field and not an empty value. For a reusable email control, that means the consumer should use '' as the initial value, and not leave the property undefined. See the Form Models guide for details on choosing initial values.
In addition, controls should not register their own effects for state management. The form system manages field state through internal effects. This means that your control receives state updates through input signals. If a control needs to transform values, use linkedSignal() as shown in the "Value transformation" section rather than an effect().
次のステップ
このガイドでは、シグナルフォームと連携するカスタムコントロールの構築について説明しました。関連ガイドでは、シグナルフォームの他の側面について探求します: