Angularのフォームは標準的なHTMLと非常に相性が良いです。input、textarea、selectは、追加の手間なしにReactive Formsに組み込むことができます。フレームワークはそれらのイベント、値、および状態を理解しています。
しかし、現代のアプリケーションは標準的な要素だけで済むことは稀です。スターレーティング・ウィジェット、複合的な日付セレクター、カスタムカラーピッカーなどが必要になるかもしれません。これらをフォームグループに投入しても、Angularはそれを単なる「動かないHTML」として扱います。patchValueは何もせず、バリデーターは無視します。フォームはユーザーがコントロールを操作したことを検知できず、form.disable()を実行してもカスタムウィジェットは操作可能なままになってしまいます。
これこそが、ControlValueAccessorが存在する理由です。
ControlValueAccessorが実際に行っていること
ControlValueAccessorは、カスタムコンポーネントをフォームの「第一級市民(first-class citizen)」へと変えるための契約(contract)です。これはAngular Forms APIと独自のUIとの間の翻訳者として機能します。正しく実装すれば、フォームの観点からは、コンポーネントはネイティブのinputと区別がつかなくなります。組み込み要素と同じように、値を受け取り、変更を通知し、タッチ状態を報告し、無効化(disabled)状態を尊重できるようになります。
このインターフェースには4つの特定のメソッドが必要です。それぞれが異なる方向の通信を処理します。
writeValue: フォームからコンポーネントへ
writeValue(obj)はインバウンド(受信)の経路です。フォームモデルが更新され、UIに新しい値をプッシュする必要があるたびに、Angularはこのメソッドを呼び出します。フォームグループに対してpatchValue({ rating: 4 })を実行すると、その値「4」がwriteValueを通じてコンポーネント内に届きます。フォームをリセットすると、writeValueは新しい初期値またはnullを受け取ります。このメソッド内でのあなたの役割は、その送られてきたデータをコンポーネントの内部状態にマッピングすることです。例えばカラーピッカーを作成している場合、writeValueは#ff4400のような16進数の文字列を受け取り、その色が選択されていることを示すようにビューを更新する必要があります。
ここで実用上の注意点があります。特に動的にレンダリングされるコンポーネント、ダイアログ、またはタブインターフェース内では、ビューが完全に初期化される前にAngularがwriteValueを呼び出すことがあります。コンポーネントが早すぎるタイミングでDOMや子コンポーネントに触れようとすると、ランタイムエラーが発生する可能性があります。堅実なパターンとしては、値をローカルプロパティに保存しておき、ビューが初期化された後に適用するか、未定義の子リファレンスに対するガードを設けることです。writeValueがテンプレートが安定した時にのみ実行されると決して決めつけないでください。
registerOnChange: コンポーネントからフォームへ
registerOnChange(fn)はアウトバウンド(送信)の経路を設定します。Angularはコールバック関数を渡してくるので、その参照を保持しておく必要があります。ユーザーがコンポーネント内の値を変更するたびに、その関数を新しい値とともに呼び出します。スターレーティング・コンポーネントの場合、ユーザーが3番目の星をクリックしたときに、保持しておいたコールバックを3で呼び出します。その呼び出しはFormControlへと流れ戻り、モデルを更新し、valueChangesの購読をトリガーし、バリデーターを再実行します。
このステップを飛ばすことは、フォームを密かに壊してしまう最も一般的な方法です。ウィジェット自体は動いているように見えるかもしれません。ユーザーには星が光ったり、色が変化したり、日付が入力されたりするのが見えます。しかし、フォームモデルは決して更新されません。バリデーターは古いデータのまま評価を続け、送信ハンドラーは古い値を送信します。コンポーネントは機能しているように見えますが、フォームは実質的に「盲目」なのです。カスタムコントロールがユーザーの入力を受け付けているのに、周囲のフォームがそれに気づかない場合、原因はほぼ間違いなくこれです。
registerOnTouched: インタラクションの報告
フォームは値だけでなく、ユーザーがフィールドを操作したかどうかも追跡します。Angularは「touched」状態を使用して、いつバリデーションエラーを表示するのが適切かを判断します。必須のテキスト入力が、ページを読み込んだ瞬間に赤く点滅すべきではありません。ユーザーがタブで移動したり、他の場所をクリックしたりするまで待つべきです。
ネイティブのinputは、blurイベントを通じてこれを自動的に処理します。カスタムコンポーネントはそうではありません。これらのインタラクションを自身で報告するために、registerOnTouched(fn)を使用する必要があります。Angularは別のコールバックを提供します。ユーザーがコントロールに対して意味のある操作を行ったと判断したときに、そのコールバックを呼び出します。
正確なタイミングはコンポーネントによって異なります。テキストのようなカスタム入力の場合、blur時に呼び出すかもしれません。スターレーティングであれば、最初のクリックが適切なタイミングでしょう。ポップオーバーが開くカラーピッカーの場合は、パレットが閉じるまで待つかもしれません。重要なのは一貫性です。もしtouchedのコールバックを一度も呼び出さないと、Angularはコントロールをpristineのままにし続けます。ユーザーが明らかに編集を終えた後でも、バリデーションエラーは隠れたままになります。これは混乱を招き、ユーザーエクスペリエンスを損なうことにつながります。
setDisabledState: フォームのコマンドを尊重する
動的なフォームでは、ビジネスロジックに基づいてフィールドの有効・無効が絶えず切り替わります。FormControl に対して .disable() を呼び出したとき、Angular はカスタムコンポーネントがそれに応答することを期待しています。setDisabledState(isDisabled) は boolean 値を受け取ります。これが true のとき、UI をロック(無効化)する必要があります。
これは単にクリックを無視するだけではありません。内部のボタンを無効にし、フォーカス可能な状態を解除し、不透明度を下げたり pointer-events: none を適用したりといった視覚的な処理を行うべきです。このメソッドを無視すると、フォームモデルが「無効」としているにもかかわらず、コンポーネントが完全にインタラクティブなままになってしまいます。これは追跡困難なバグの原因となります。ユーザーは、フォームが拒否するはずの値に変更できてしまいます。保存ボタンが無効な状態に基づいて有効になってしまうこともあります。フォームグループと UI が乖離してしまうのです。
優れたカスタムコントロールは、setDisabledState を後回しにするのではなく、第一級の要件として扱います。
デバッグ時間を浪費するミス
このインターフェースに慣れていない開発者が陥りやすい、いくつかの典型的なミスがあります。
変更コールバックの呼び出し忘れ。 コンポーネントは内部状態を更新しますが、フォームにはそれが伝わりません。バリデーターが停止し、親フォームが古いデータを送信してしまいます。ユーザーが新しい値を確定した瞬間に、保存しておいた onChange 関数を必ず実行してください。
touched コールバックのスキップ。 これを行わないと、Angular はコントロールを「touched(触れた)」状態としてマークしません。touched や dirty 状態に紐付けられたエラーメッセージが表示されなくなります。ユーザーは、見た目は正しいのに送信できないフォームを前にして、何が間違っているのか視覚的な手がかりがないまま立ち尽くすことになります。
無効状態(disabled state)の軽視。 フォーム側は無効だと思っているのに、見た目上は有効なコントロールは、信頼の境界を壊してしまいます。ユーザーは入力を続けたりクリックしたりできますが、モデルはそれを無視します。あるいはさらに悪いことに、同期サイクル中にモデルがユーザーの入力を散発的に上書きしてしまうこともあります。
NG_VALUE_ACCESSOR プロバイダーの欠落。 これは「サイレント・キラー(静かなる殺し屋)」です。4つのメソッドを実装していても、コンポーネントの providers 配列に NG_VALUE_ACCESSOR を追加し忘れると、Angular はあなたのコンポーネントを値アクセサとして登録しません。コードはコンパイルされ、ビューもレンダリングされます。しかし、何もバインドされません。エラーメッセージすら出ず、コンポーネントがフォームから完全に浮いた状態になります。必ずデコレータのメタデータに含めてください。
Signals、バリデーター、そしてモダンな Angular
ControlValueAccessor はレガシーな API ではありません。モダンな Angular 開発にもきれいに適合します。内部状態を Signals、プレーンなプロパティ、あるいは RxJS の Subject で管理しているかに関わらず、これら4つのメソッドはフォームモジュールとの公開契約(パブリック・コントラクト)であり続けます。writeValue で値を受け取り、Signals や状態を変化させ、Angular が提供するコールバックを通じて値を放出します。
標準のバリデーターは修正なしで動作します。Validators.required、Validators.min、Validators.pattern、およびカスタムのクロスフィールド・バリデーターはすべて、CVA を介したコンポーネントをネイティブの input と全く同じように評価します。フォームコントロールは「値」と「状態」を見ています。その値がテキストボックスから来たのか、自作の月選択ピッカーから来たのかは気にしません。
このポータビリティ(移植性)こそが、デザインシステムや共有 UI ライブラリにおいて CVA が重要である理由です。あるチームが堅牢な電話番号入力やファイルアップロードウィジェットを作成します。彼らはインターフェースを一度実装するだけです。組織内の他のすべてのチームは、追加の配線なしで、それを Reactive Forms に組み込むことができます。コンポーネントは予測可能な動作をし、一律にバリデーションが行われ、すべての機能モジュールで一貫して無効化されます。
真の教訓
ControlValueAccessor は、単に面接の質問のために暗記すべきインターフェースではありません。それは、カスタムコンポーネントがネイティブの HTML 要素と同等の立場で Angular のフォームエコシステムに参加できるようにするための「架け橋」です。これをマスターするということは、ウィジェットとフォームの間の完全な対話を理解することを意味します。つまり、値を受け取り、変更を報告し、タッチを通知し、無効状態を尊重することです。これら4つの要素を正しく扱えれば、それを使う開発者にとって「存在を感じさせない」ほど自然で、複雑かつ再利用可能なフォームコントロールを構築できます。それこそが、プロフェッショナルな Angular コンポーネントの証です。
