Angular v17.2で実装されたModel Inputsを使ってカスタムフォームコントロールを実装してみよう。SignalベースのAPIが揃ってきたことで、ControlValueAccessor
の実装もかなり簡潔になった。
TimeInputComponent
次のようなTime
型を読み書きするControlValueAccessor
を題材にする。
export type Time = {
hour: number;
minute: number;
};
今回は素朴にselect要素で時間と分を選択するようなコンポーネントを考える。UIだけ実装すると次のようになる。
@Component({
selector: 'app-time-input',
standalone: true,
imports: [DecimalPipe],
template: `
<div>
<select>
@for(i of hourOptions; track i) {
<option [value]="i">{{ i | number : '2.0' }}</option>
}
</select>
<span>:</span>
<select>
@for(i of minuteOptions; track i) {
<option [value]="i">{{ i | number : '2.0' }}</option>
}
</select>
</div>
`,
styles: `:host { display: inline-block; }`,
})
export class TimeInputComponent {
readonly hourOptions = getRange(0, 23);
readonly minuteOptions = getRange(0, 59);
}
これをAngular Formsと連携できるカスタムフォームコントロールとして実装しよう。まずは value
というTime
型のModel Inputを作成する。これを次のようにNgModel
を使ってselectと紐付ける。
@Component({
selector: 'app-time-input',
standalone: true,
imports: [FormsModule, DecimalPipe],
template: `
<div>
<select [ngModel]="value().hour" (ngModelChange)="updateHour($event)">
@for(i of hourOptions; track i) {
<option [value]="i">{{ i | number : '2.0' }}</option>
}
</select>
<span>:</span>
<select [ngModel]="value().minute" (ngModelChange)="updateMinute($event)">
@for(i of minuteOptions; track i) {
<option [value]="i">{{ i | number : '2.0' }}</option>
}
</select>
</div>
`
})
export class TimeInputComponent {
readonly value = model<Time>({ hour: 0, minute: 0 });
readonly hourOptions = getRange(0, 23);
readonly minuteOptions = getRange(0, 59);
updateHour(value: number) {
this.value.update((curr) => ({ ...curr, hour: value }));
}
updateMinute(value: number) {
this.value.update((v) => ({ ...v, minute: value }));
}
}
これだけでも、親からは <app-time-input [(value)]="...">
という形で双方向バインディング可能になった。ここからはさらに<app-time-input [(ngModel)]="...">
や<app-time-input [formControl]="...">
のようにAngular Formsとの連携が可能となるように、ControlValueAccessor
としての実装を加える。
TImeInputComponent
クラスでControlValueAccessor
インターフェースを実装すると次のようになる。Angular Formsからカスタムフォームコントロールであることが識別できるようにNG_VALUE_ACCESSOR
として自身を提供することを忘れないようにする。
@Component({
selector: 'app-time-input',
standalone: true,
imports: [FormsModule, DecimalPipe],
template: `...`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: TimeInputComponent,
multi: true,
},
],
})
export class TimeInputComponent implements ControlValueAccessor {
readonly value = model<Time>({ hour: 0, minute: 0 });
#onChangeListener = (_: Time) => {};
constructor() {
// Emit value change to form control
effect(() => {
this.#onChangeListener(this.#value());
});
}
// ControlValueAccessor implementation
writeValue(value: Time): void {
this.value.set(value);
}
registerOnChange(fn: (v: Time) => void): void {
this.#onChangeListener = fn;
}
registerOnTouched(fn: any): void {
// noop
}
}
Signalベースになったことでのポイントは、フォームモデルへ値の変更を伝えるためのコールバック関数 #onChangeListener
の呼び出しが、value
SignalのEffectを書くだけで完結している点だ。どのような経緯であれvalue
に変更があればフォームモデルに同期できるため、同期漏れの心配がない。また、コンポーネントが破棄されたあとのメモリリークの心配もない。
#onChangeListener = (_: Time) => {};
constructor() {
effect(() => {
this.#onChangeListener(this.value());
});
}
registerOnChange(fn: (v: Time) => void): void {
this.#onChangeListener = fn;
}
同期漏れの心配はないが、逆に同期しすぎることはありえる。特に今回の例ではTime
型はオブジェクトなので、value
が更新されるたびに参照が変わる。等値ではないことになるため、実際の値が変わっていなくてもvalue
がセットされるたびにフォームモデルへ通知されてしまう。
model()
は signal()
や computed()
と違い、equal
オプションを持たないため、等値判定を変更できない。これは input()
も同様である。オプションの追加を求めるイシューがあるため、賛同する人がいればイシューに対してさらなるVoteをお願いしたい。https://github.com/angular/angular/issues/54111
この問題を解決するために、新たに #changedValue
Signalを作成する。これはvalue
Signalから派生し、Time
型のための等値判定関数を与えていることで、実際の値が変更したときだけ通知されるSignalになる。
// 等値判定関数
export function isEqualTime(a: Time, b: Time) {
return a.hour === b.hour && a.minute === b.minute;
}
export class TimeInputComponent implements ControlValueAccessor {
readonly value = model<Time>({ hour: 0, minute: 0 });
readonly #changedValue = computed(() => this.value(), { equal: isEqualTime });
constructor() {
// Emit value change to form control
effect(() => {
this.#onChangeListener(this.#changedValue());
});
}
}
動作するサンプルは以下。現実のユースケースではもう少し複雑なコンポーネントになるが、基本的な構造はこの形から始めて拡張していけるはずだ。また、Angular本体のほうでもよりSignal APIとの親和性を高めるためのフォームAPIの拡張を計画しているため、それが来るともっとボイラープレートを減らせるかもしれない。それに備える意味でも今からカスタムコントロールをSignalベースに寄せていくのは無駄にならないだろう。