Marginalia

Angular: Model Inputsを使ったカスタムフォームコントロール実装例

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ベースに寄せていくのは無駄にならないだろう。