- Angular
ControlValueAccessorの使い方|自作の入力部品をフォーム対応
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期連続の黒字決算。