ControlValueAccessor は、自作の入力部品(星評価・金額入力など)を、formControl や ngModel で使えるようにするための約束(インターフェース)です。 4つのメソッドを実装し、NG_VALUE_ACCESSOR という名前で自分自身を登録すると、Angular のフォームが値の読み書きをしてくれます。

メソッド 誰が・いつ呼ぶ 部品の中ですること
writeValue(value) フォーム → 部品。初期値・setValue()・reset() のとき 受け取った値を画面に出す
registerOnChange(fn) フォームが初期化のときに1回 fn をとっておき、ユーザーが値を変えたら fn(新しい値)
registerOnTouched(fn) フォームが初期化のときに1回 fn をとっておき、フォーカスが外れたら fn()
setDisabledState(isDisabled)(任意) disable()・enable() のとき。v22.2.2 では初期化時にも呼ばれた ボタンや input を無効にする
providers: [
  { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
],

確認環境:Angular 22.2.2(@angular/core・@angular/forms)、Node.js 24.15.0(Linux)。ng new のプロジェクトで、TestBed+Vitest 5.0.4(jsdom)で部品を動かし、メソッドが呼ばれる順をログに出しました。Signal Forms の API 名は angular.dev と、@angular/forms 22.2.2 の型定義で確かめました(確認日 2026年10月12日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ControlValueAccessorの使い方|自作の入力部品をフォーム対応」

完成コード①:星評価(1〜5)

クリックで 1〜5 を選ぶ部品です。値は number、何も選んでいないときは 0 として表示します。

// star-rating.ts
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    <div role="radiogroup" aria-label="評価" (focusout)="onTouched()">
      @for (n of stars; track n) {
        <button type="button" role="radio"
          [attr.aria-checked]="n === value()"
          [disabled]="disabled()"
          [class.on]="n <= value()"
          (click)="select(n)">★</button>
      }
    </div>`,
  styles: `
    button { border: 0; background: none; font-size: 24px; color: #ccc; cursor: pointer; }
    button.on { color: #f5a623; }
    button:disabled { cursor: not-allowed; opacity: .5; }`,
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
  ],
})
export class StarRating implements ControlValueAccessor {
  readonly stars = [1, 2, 3, 4, 5];
  protected readonly value = signal(0);
  protected readonly disabled = signal(false);
  private onChange: (v: number) => void = () => {};
  protected onTouched: () => void = () => {};

  // フォーム → 部品
  writeValue(v: number | null): void {
    this.value.set(v ?? 0);          // ngModel は最初に null を渡してくる
  }
  registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); }

  // 部品 → フォーム(ユーザーの操作のときだけ呼ぶ)
  protected select(n: number) {
    this.value.set(n);
    this.onChange(n);
  }
}

ポイントは3つです。

  • useExisting で「この部品自身」を登録する。forwardRef は、クラスの定義より前で自分を参照するために使う
  • multi: true を付ける。NG_VALUE_ACCESSOR は、組み込みのアクセサーと並べて探される一覧のため
  • 表示用の値・無効状態を Signal で持つと、v22 の既定(OnPush)でも writeValue の変更が画面に出る

Reactive Forms で使う

@Component({
  imports: [ReactiveFormsModule, JsonPipe, StarRating],
  template: `
    <form [formGroup]="form">
      <app-star-rating formControlName="rating" />
    </form>
    <p>{{ form.value | json }}</p>`,
})
export class ReviewForm {
  form = new FormGroup({
    rating: new FormControl(3, { nonNullable: true, validators: [Validators.min(1)] }),
  });
}

ngModel(テンプレート駆動フォーム)で使う

@Component({
  imports: [FormsModule, StarRating],
  template: `<app-star-rating [(ngModel)]="rating" name="rating" />`,
})
export class ReviewPage { rating = 4; }

部品の中は、どちらで使っても同じコードです。 Reactive Forms の組み方は、関連記事「Angularリアクティブフォームの使い方|FormGroup・バリデーション」で扱っています。ngModel の使い方は、関連記事「Angularテンプレート駆動フォーム入門|ngModelとエラー表示」で扱っています。

4つのメソッドが呼ばれる順(ログで確認)

各メソッドの先頭でログを取り、使い方ごとに順番を比べました。

使い方 呼ばれた順
[formControl]="new FormControl(3)" constructor → writeValue(3) → setDisabledState(false) → registerOnChange → registerOnTouched
new FormControl({ value: 2, disabled: true }) constructor → writeValue(2) → setDisabledState(true) → registerOnChange → registerOnTouched
[(ngModel)]="rating"(rating = 4) constructor → writeValue(null) → setDisabledState(false) → registerOnChange → registerOnTouched →(少し後に)writeValue(4)
[(ngModel)] + [disabled]="true" …registerOnTouched → setDisabledState(true) → writeValue(4)
Signal Forms の [formField] constructor → registerOnChange → registerOnTouched → writeValue(2) → setDisabledState(false)

ここから分かることです。

  • Reactive Forms と ngModel では、writeValue が registerOnChange より先に呼ばれた。 writeValue の中で onChange を呼ぶ作りにすると、その時点ではまだ空の関数で、後で呼ぶと別の問題が起きる(後述)
  • ngModel は最初に writeValue(null) を呼び、本当の値は少し後で来た。 null を受けても壊れないように書く(上のコードの v ?? 0)
  • setDisabledState(false) も初期化のときに呼ばれた。 無効でなくても呼ばれる前提で書く

操作したときのフォームの状態

new FormControl(3) につないで操作した結果です。

操作 呼ばれたメソッド フォームの状態
ctrl.setValue(5) writeValue(5) 星が5つ点く。dirty は false のまま
2つめの星をクリック (部品が onChange(2) を呼ぶ) value: 2、dirty: true、touched: false
フォーカスが外れる(focusout) (部品が onTouched() を呼ぶ) touched: true
ctrl.disable() setDisabledState(true) ボタンがすべて disabled。ctrl.value は 2 のまま
ctrl.reset() writeValue(null) value: null

dirty は onChange、touched は onTouched を部品が呼んだときにだけ変わりました。 エラー表示を「touched のとき」に出すフォームでは、onTouched を呼ばない部品だと、いつまでもエラーが出ません。

完成コード②:金額入力(3桁カンマ表示・値は数値)

表示は「1,980」、フォームの値は 1980(数値)にする部品です。全角数字で入力されても数値にします。

// yen-input.ts
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

const fmt = new Intl.NumberFormat('ja-JP');
const toHalf = (s: string) => s.replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xfee0));

@Component({
  selector: 'app-yen-input',
  template: `
    <span class="yen">
      <input type="text" inputmode="numeric" [value]="text()" [disabled]="disabled()"
             (input)="onInput($any($event.target).value)" (blur)="onBlur()" />
      <span>円</span>
    </span>`,
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => YenInput), multi: true }],
})
export class YenInput implements ControlValueAccessor {
  protected readonly text = signal('');
  protected readonly disabled = signal(false);
  private onChange: (v: number | null) => void = () => {};
  private onTouched: () => void = () => {};

  writeValue(v: number | null): void {
    this.text.set(v == null ? '' : fmt.format(v));
  }
  registerOnChange(fn: (v: number | null) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(d: boolean): void { this.disabled.set(d); }

  protected onInput(raw: string) {
    const digits = toHalf(raw).replace(/[^\d]/g, '');
    this.text.set(raw);                                    // 入力中は打ったまま表示
    this.onChange(digits === '' ? null : Number(digits));  // フォームには数値
  }
  protected onBlur() {
    const digits = toHalf(this.text()).replace(/[^\d]/g, '');
    this.text.set(digits === '' ? '' : fmt.format(Number(digits)));  // 離れたらカンマ付きに
    this.onTouched();
  }
}

new FormControl<number | null>(1980) につないだ結果です。

操作 画面の表示 フォームの値
最初 1,980 1980
「12345」(全角)と入力 12345 12345
フォーカスを外す 12,345 12345(touched: true)
全部消す (空) null

入力中にカンマを付け直すと、カーソルの位置がずれやすいので、カンマはフォーカスが外れたときに付けています。

Signal Forms(v22)で自作部品を作る:FormValueControl

v22 で安定版になった Signal Forms(@angular/forms/signals)では、ControlValueAccessor の代わりに FormValueControl<T> を実装します。 angular.dev の説明では、必須は value という model() だけで、disabled・touched・invalid・errors などは、必要なものだけ input() で受け取ります。フォーカスが外れたことは touch という output() で伝えます。

// star-signal.ts
import { Component, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'app-star-signal',
  template: `
    <div role="radiogroup" aria-label="評価" (focusout)="touch.emit()">
      @for (n of stars; track n) {
        <button type="button" role="radio" [attr.aria-checked]="n === value()"
          [disabled]="disabled()" [class.on]="n <= value()" (click)="value.set(n)">★</button>
      }
    </div>
    @if (touched() && invalid()) { <p class="err">評価を選んでください</p> }`,
})
export class StarSignal implements FormValueControl<number> {
  readonly stars = [1, 2, 3, 4, 5];
  readonly value = model(0);          // 必須
  readonly disabled = input(false);   // 以下は使うものだけ
  readonly touched = input(false);
  readonly invalid = input(false);
  readonly touch = output<void>();
}
// 使う側
import { form, FormField, min, disabled } from '@angular/forms/signals';

@Component({
  imports: [FormField, StarSignal],
  template: `<app-star-signal [formField]="f.rating" />`,
})
export class ReviewSignalForm {
  model = signal({ rating: 0, locked: false });
  f = form(this.model, p => {
    min(p.rating, 1);
    disabled(p.rating, ({ valueOf }) => valueOf(p.locked));
  });
}

動かした結果です。

操作 結果
最初 星 0 個、invalid: true、touched: false(エラー文は出ない)
フォーカスを外す(touch を emit) touched: true、エラー文が出た
4つめをクリック model が {"rating":4,"locked":false}、dirty: true、invalid: false
model の rating を 2 に変更 星が2つに
locked を true に disabled() が true、ボタンがすべて disabled

registerOnChange などの関数を受け渡す代わりに、Signal の input・model でつながるので、コードが短くなりました。 Signal Forms 全体の書き方は、関連記事「Angular Signal Forms入門|v22安定版のAPI早見表と完成コード」で扱っています。

既存の部品と組み合わせられるか

angular.dev には、Signal Forms 用の部品は Reactive Forms・テンプレート駆動フォームでもそのまま使える、とあります。両方向で試しました。

組み合わせ 結果
ControlValueAccessor の星評価 + [formField] 星2つで表示、クリックで model が {"rating":5} に
FormValueControl の星評価 + [formControl] 星2つで表示、クリックで FormControl の値が 5 に

どちらも動いたので、既存の ControlValueAccessor 部品を残したまま、フォームを少しずつ Signal Forms に移すことができます。

touched・disabled が効かない原因表

うまく動かない部品をわざと作り、何が起きるかを確かめました。

症状 原因 確かめた結果・直し方
NG01203: No value accessor for form control name: 'rating' NG_VALUE_ACCESSOR を登録していない providers に useExisting・multi: true で登録
NG01203: No value accessor for form control unspecified name attribute 同上([formControl] で使ったとき) 同上
初期値が画面に出ない useClass で登録した 別のインスタンスが作られ、writeValue が画面の部品に届かなかった。useExisting にする
setValue() しただけで dirty になる writeValue の中で onChange を呼んでいる setValue(2) で dirty: true、valueChanges が2回。onChange はユーザー操作のときだけ
disable() しても入力できる setDisabledState を実装していない status は DISABLED なのに input は有効のままだった。実装して画面に反映
touched にならず、エラー文が出ない onTouched を呼んでいない クリックだけでは touched: false。blur・focusout で onTouched()
setValue() しても画面が変わらない 表示用の値が普通のフィールド(v22 は OnPush が既定) 初期値の 1 は出たが、setValue(9) 後も 1 のままだった。Signal で持つ
ngModel で最初に TypeError writeValue(null) を想定していない n.toFixed(1) と書いた部品で Cannot read properties of null (reading 'toFixed')。null を受けても動くように書く

NG01203 は、angular.dev のエラー一覧でも「自作のフォーム部品には value accessor の登録が要る」と説明されています。v22 の変更検知(OnPush が既定)については、関連記事「Angular変更検知とOnPush|markForCheckの使い方」で扱っています。

よくある質問

Q. (blur) と (focusout) のどちらで onTouched を呼びますか。 A. 星評価のようにボタンが並ぶ部品では、中のボタン間を移動するたびに blur が出ます。今回は外側の要素で、子要素のフォーカス移動も拾える focusout を使いました。input が1つの部品なら (blur) です。

Q. 部品の中で @Input() value を作るのはどうですか。 A. ControlValueAccessor では、値は writeValue で受け取ります。value を input にもすると、フォームからの値と input からの値の2つの入口ができて、どちらが正しいか分かりにくくなります。Signal Forms の FormValueControl では、逆に value を model() で持つ決まりです。親子の受け渡しは関連記事「Angular input・output・modelの使い方|親子の受け渡し」で扱っています。

Q. 入力チェックは部品の中に書きますか。 A. angular.dev の Signal Forms の説明では、チェックの決まりはフォーム側(スキーマ)に置き、部品は結果を表示するだけにする方針です。Reactive Forms でも、Validators.min(1) のようにフォーム側に付けると、部品を別の場面で使い回せます。独自のチェックは関連記事「Angularカスタムバリデーターの作り方|非同期・相関チェック」で扱っています。

まとめ

  • ControlValueAccessor は writeValue・registerOnChange・registerOnTouched・setDisabledState の4つ
  • 登録は { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => 部品), multi: true }
  • v22.2.2 では writeValue が registerOnChange より先。ngModel は最初に writeValue(null)
  • dirty は onChange、touched は onTouched を部品が呼んだときだけ変わる
  • v22 の Signal Forms では FormValueControl(value = model())。既存の部品と 両方向で組み合わせられた

参考・出典

確認日はいずれも 2026年10月12日です。

  • Angular API「ControlValueAccessor」:https://angular.dev/api/forms/ControlValueAccessor
  • Angular「NG01203: Missing value accessor」:https://angular.dev/errors/NG01203
  • Angular「Custom controls」(Signal Forms):https://angular.dev/guide/forms/signals/custom-controls
  • JavaScript Conference「Angular Reactive Forms: Building Custom Form Controls」:https://javascript-conference.com/blog/angular-reactive-forms-building-custom-form-controls/

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ControlValueAccessorの使い方|自作の入力部品をフォーム対応」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?