🚥

Angular v21 の Experimental Signal Forms を触ってみた

に公開

これはAngular Advent Calendar 2025の6日目の記事です。

https://qiita.com/advent-calendar/2025/angular

はじめに

v18かv19あたりを触ったのを最後にしばらくAngular(というかWebフロントエンド)から離れていたのですが、久しぶりにAngular Blogを見てみたら、Signalの登場時からずっと欲しかったSignal Formsが実験的APIとしてリリースされていたので簡単に調べたことをまとめていこうかなと思います。

Signal Formsの基本的な利用方法

Signal Formsの基本的な要素はFieldディレクティブとform関数です。
以下は単一のコントロールを利用する例です。

@Component({
  selector: 'app-root',
  imports: [Field],  // Fieldディレクティブをインポート
  template: `
    <div>
      <!-- fieldディレクティブにFieldTreeをバインドする -->
      <input type="text" [field]="valueField" />
    </div>
    <div>{{ value() }}</div>
  `
})
export class App {
  // フォームの元となるsignalを準備
  value = signal('Angular')
  // FieldTreeを作成
  valueField = form(this.value);
}

テンプレート駆動型フォームやリアクティブフォームではFormsModuleやReactiveFormsModuleをインポートすることで、ngModelやformControlのようなディレクティブが利用できるようになり、これらのディレクティブがDefaultValueAccessorなどのControlValueAccessorを介して値の更新や変更の検知を行っていました。
しかし、Signal FormsではControlValueAccessorは必要ありません。

フォームに複数の入力欄がある場合は、signalにオブジェクトを渡せばいいだけです。

@Component({
  selector: 'app-root',
  imports: [Field, JsonPipe],
  template: `
    <!-- FieldTreeからFieldTreeを取れるのでバインドする -->
    <div>
      <input type="text" [field]="modelForm.name" />
    </div>
    <div>
      <input type="number" [field]="modelForm.age" />
    </div>
    <div>{{ model() | json }}</div>
  `
})
export class App {
  // signalの初期値をオブジェクトにする
  model = signal({
    name: '太郎',
    age: 25
  });
  modelForm = form(this.model);
}

プログラムからの値の変更はWritableSignalを通して行います。

// フォームの元となっているシグナルを更新する方法
this.model.set({ name: '花子', age: 20 });

// FieldState経由でシグナルの値を更新する方法
// FieldTreeを関数として呼び出すことでFieldStateを取得できる
this.modelForm.name().value.set('花子');

状態の変更、バリデーション

各フィールドの状態の変更やバリデーションは、form関数の引数に関数を渡すことで設定可能です。
以下は条件付きでコントロールやバリデーションの有効/無効を切り替える例です。

@Component({
  selector: 'app-root',
  imports: [Field],
  styles: `.error { color: red; } .invalid { border-color: red; }`,
  template: `
    @let valueErrors = modelForm.value().errors();
    @let invalid = valueErrors.length > 0;
    <div>
      <input type="checkbox" [field]="modelForm.checked" />
      <input type="text" [field]="modelForm.value" [class.invalid]="invalid" />
      @if(invalid) {
        <ul class="error">
          @for(error of valueErrors; track error) {
            <li>{{ error.message }}</li>
          }
        </ul>
      }
    </div>
  `
})
export class App {
  model = signal({
    checked: true,
    value: 'Angular'
  });

  modelForm = form(this.model, tree => {
    // 条件付きでコントロールを無効化したり
    disabled(tree.value, ({ valueOf }) => !valueOf(tree.checked));
    // バリデーションを設定できる
    required(tree.value, {
      message: '必須です。',
      when: ({ valueOf }) => valueOf(tree.checked)
    });
  });
}

requireddisabled以外にも、maxmaxLengthhiddenなどの関数が用意されています。

フィールドの状態やバリデーションエラーは、以下のようにFieldStateからSignalで取得できます。

const disabled = this.modelForm.name().disabled();
const invalid = this.modelForm.name().invalid();
const errors = this.modelForm.name().errors();

カスタムバリデーション

カスタムバリデーションはvalidate関数を利用することで実装可能です。
以下はパスワードを2回入力し、2つの値が一致するかを検証するカスタムバリデーションの作成例です。

@Component({
  selector: 'app-root',
  imports: [Field],
  styles: `.error { color: red; }`,
  template: `
    <div>
      <div>
        <label>Password1:<input type="text" [field]="modelForm.password1"/></label>
      </div>
      <div>
        <label>Password2:<input type="text" [field]="modelForm.password2"/></label>
        @let errors = modelForm.password2().errors();
        @if (errors.length > 0) {
          <ul class="error">
            @for (error of errors; track error) {
              <li>{{ error.message }}</li>
            }
          </ul>
        }
      </div>
    </div>
  `
})
export class App {
  model = signal({
    password1: '',
    password2: ''
  });

  modelForm = form(this.model, tree => {
    validate(tree.password2, ({ valueOf }) => {
      const password1 = valueOf(tree.password1);
      const password2 = valueOf(tree.password2);

      return password1 === password2
        ? []
        : [{
          kind: 'password',
          message: 'パスワードが一致しません。'
        }]
    });
  });
}

Fieldディレクティブを覗いてみる

基本をある程度押さえたところで、Fieldディレクティブの実装を覗いてみます。

https://github.com/angular/angular/blob/21.0.0/packages/forms/signals/src/api/field_directive.ts#L55-L110

詳細な実装については省きますが、驚いたことにControlValueAccessorをDIしているようです。
ということはControlValueAccessorを利用したカスタムコントロールと互換性がある?
コメント部分に答えが書いてありました。

https://github.com/angular/angular/blob/21.0.0/packages/forms/signals/src/api/field_directive.ts#L36-L54

このディレクティブは

  1. ネイティブ要素(input / textarea)
  2. FormValueControl / FormCheckboxControlを実装したカスタムコントロール
  3. ControlValueAccessorをDI可能な要素

で利用できるようですね。

カスタムコントロールの実装

FormValueControlを利用した最小の実装は以下のようになります。

@Component({
  selector: 'app-custom-input',
  template: `
    <input
      type="text"
      [value]="value()"
      (input)="value.set($event.target.value)"
    />
  `
})
export class CustomInput implements FormValueControl<string> {
  // ModelSignalを準備するだけ!
  value = model('Angular');
}

ControlValueAccessorはインターフェースの実装やNG_VALUE_ACCESSORの設定が少し煩雑でしたが、こちらはとても簡単でわかりやすいですね。

また、FormValueControlやFormCheckboxControlはコントロールの状態に関するプロパティを用意すると、自動的に状態をセットしてくれるようになります。

export class CustomInput implements FormValueControl<string> {
  value = model('');

  // バリデーションエラーが自動的に適用される
  errors = input<readonly WithOptionalField<ValidationError>[]>([]);
  // コントロールの状態なども自動的に適用される
  disabled = input(false);
}

errorsdisabled以外にもさまざまなプロパティを定義することができます。

https://github.com/angular/angular/blob/21.0.0/packages/forms/signals/src/api/control.ts#L13-L104

ControlValueAccessorとの互換性

ControlValueAccessorとの互換性はしっかり確認しておきたいところ。
検証用に以下のControlValueAccessorを実装したコンポーネントを準備しました。
Signal Formsを利用してから改めて実装してみると少し面倒なAPIに感じますね。

@Component({
  selector: 'app-custom-input',
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: CustomInput,
      multi: true
    }
  ],
  template: `
    <input
      #input
      (input)="onChange($event.target.value)"
      (blur)="onTouched()"
      [disabled]="disabled()"
    />
  `
})
export class CustomInput implements ControlValueAccessor {
  ref = viewChild.required<ElementRef<HTMLInputElement>>('input');
  nativeElement = computed(() => this.ref().nativeElement);
  disabled = signal(false);

  onChange = (value: any) => {};
  onTouched = () => {};

  writeValue(obj: any): void {
    this.nativeElement().value = obj;
  }
  registerOnChange(fn: (value: any) => void): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.disabled.set(isDisabled);
  }
}

呼び出し側ではfieldディレクティブを利用してFieldTreeをバインドします。

@Component({
  selector: 'app-root',
  imports: [Field, CustomInput],
  template: `
    <app-custom-input [field]="valueField" />
    value: {{ value() }}
  `
})
export class App {
  value = signal('Angular');
  valueField = form(this.value);
}

本当に動いてびっくりです。
ただし、Fieldディレクティブは、NgModelFormControlDirectiveなどのディレクティブとは異なり、NgControlベースの実装ではありません。
そのためFieldディレクティブではNG_VALIDATORSNG_ASYNC_VALIDATORSは利用されず、これらのバリデーションを実行しません。
また、これは後方互換のための機能なので、基本的にはネイティブ要素(input / textarea)かFormValueControlやFormCheckboxControlを実装したカスタムコントロールを利用しましょう。

リアクティブフォームとの互換性

form関数の代わりにcompatForm関数を利用することで、FormControlを混在させたFieldTreeを作成することができるようです。

export class Sample {
  model = signal({
    name: '太郎',
    age: new FormControl(50, { nonNullable: true, validators: [Validators.required] })
  });

  modelForm = compatForm(this.model);

  example() {
    // これは普通のFieldState
    const nameField = this.modelForm.name();

    // これはCompatFieldState
    const ageField = this.modelForm.age();

    // CompatFieldStateは`control()`でFormControlを取得可能
    const control = ageField.control();

    // また、通常のFieldStateと同様にWritableSignalを通して値の取得/更新が可能
    const value = ageField.value;
    console.log(value());
    value.set(1000);

    // エラーも取れる
    // CompatFieldStateは従来のValidationErrorsではなく、`ValidationError.WithField[]`を返す
    // FormControlに設定したバリデーションは`CompatValidationError`として返される
    const errors = ageField.errors();
  }
}

詳細は以下
https://github.com/angular/angular/blob/21.0.0/packages/forms/signals/compat/src/api/compat_form.ts

最後に

久しぶりにAngularを触りましたが、Ivy、Standalone、Signalと進化を続け、そこへZonelessやSignal Formsが登場したことでフレームワークとしてさらに洗練されたと感じました。
Signal Formsはまだ実験的なAPIですが、書き味もいい感じですし、正式にリリースされるのが楽しみです。

明日は@da1chiさんです!

Discussion