Marginalia

Angular: disabled状態を持つカスタムコントロールとSignals

Angularでカスタムコントロールとして機能するコンポーネントを実装する場合、そのコントロールに disabled プロパティがある場合、Signals で実装するのは少し工夫が必要になる。端的に言えば input()model() だけでは実現できない。

disabled の実装

次のようなテンプレートで用いられる <app-checkbox> コンポーネントを想定する。このコンポーネントはHTML標準の<input>によるチェックボックスと同じくコントロールを不活性にする disabled プロパティを持つとする。また、<input> と同じく disabled 属性によって不活性にすることもできるとする。さらに、AngularのForms APIによってフォームコントロールの disable() メソッドから不活性にもできるとする。この3つの要件を満たせるカスタムコントロールを作ることを考える。

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AppCheckbox, ReactiveFormsModule],
  template: `
  <div>
    <app-checkbox disabled />
    <app-checkbox [disabled]="true" />
    <app-checkbox [formControl]="formControl" /> 
  </div>
  `,
})
export class App {
  formControl = new FormControl(false);

  constructor() {
    this.formControl.disable();
  }
}

Angular v18.0の現状では、この要件すべてを満たすには input()model()だけではなく@Input() デコレータを使った実装が必要になる。具体的には次のような実装がSignalを使った最も簡素なものになるだろう。不活性化状態を保持するのはプライベートフィールドの#disabled で、WritableなSignalである。そして、@Input()デコレータを付与したセッターによって#disabledに受け取った値をセットしており、同様にControlValueAccessorとしてsetDisabledStateメソッドでも受け取った値をセットしている。そしてtransform: booleanAttribute設定によって disabled 属性でも不活性化できる。

@Component({
  selector: 'app-checkbox',
  standalone: true,
  template: `
  <label>
    <input type="checkbox" 
      #input 
      [checked]="checked" 
      (change)="onInputChange(input.checked)" 
      [disabled]="disabled"> 
    <span [style.textDecoration]="disabled ? 'line-through' : 'unset'">checkbox<span>
  <label>
  `,
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: AppCheckbox, multi: true },
  ],
})
export class AppCheckbox implements ControlValueAccessor {
  readonly #disabled = signal(false);
  
  @Input({ transform: booleanAttribute })
  set disabled(value: boolean) {
    this.#disabled.set(value);
  }
  get disabled(): boolean {
    return this.#disabled();
  }

  setDisabledState(isDisabled: boolean) {
    this.#disabled.set(isDisabled);
  }
}

ここからはなぜこのような実装が必要になるのかを説明する。

Input Signalは読み取り専用である

@Input() デコレータに代わるコンポーネントのインプット宣言方法として input() 関数が導入されたが、この関数が返すInput Signalはコンポーネント内部からは読み取り専用である。したがって、setDisabledStateメソッドが実装できなくなる。よって、disabledにInput Signalは使えない。

export class AppCheckbox implements ControlValueAccessor {
  readonly disabled = input(false, { transform: booleanAttribute });

  setDisabledState(isDisabled: boolean) {
    this.disabled.set(isDisabled); // <-- ERROR!!
  }
}

Model Inputは変換できない

input() 関数と違ってコンポーネント内部で書き込み可能なものとしてmodel()関数も導入されているが、こちらの場合は disabled 属性による不活性化を可能にするための transform オプションを持たない。よって、disabledにModel Inputも使えない。

export class AppCheckbox implements ControlValueAccessor {
  readonly disabled = model(false, { 
    transform: booleanAttribute // <-- ERROR!!
  });

  setDisabledState(isDisabled: boolean) {
    this.disabled.set(isDisabled);
  }
}

なぜModel Inputが値の変換をサポートしていないのかについては、GitHubのIssueでAngularチームのテクニカルリードであるAlexからコメントがされている。双方向バインディングのメンタルモデルからすると、親からバインディングした値と内部で保持される値が違うというのは混乱を招きやすいということが主な理由だ。

以上の理由から、現状のSignal APIでは@Input()デコレータを使わずに冒頭の3つの要件を満たすことはできない。もちろん disabled 属性によって不活性化できる要件を無視すればmodel()で満足できるが、Signalsで統一された実装のために要件を妥協するかといわれればそれは選ばないだろう。@Input()デコレータを使っていても現状では非推奨化もされていないし、状態の保持がSignal化されていればそれだけでパフォーマンスの最適化やZoneless化には寄与するわけだから、特に何も失うことはない。

また、現状のSignalsとForm APIsが噛み合っていないことについてはAngular開発ロードマップの中で高い優先度で取り組まれているので、今回説明したワークアラウンドについてはそのうち不要になるだろう。それまではこのやり方が無難だと思われる。