とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

Angular Signal based Form (experimental)

はじめに

Angular に Signal API が登場して以降、いろいろな API が signal に対応するようになっている。

この流れの中 signal based な form が v21 で experimental API として提供されようとしているため、next バージョンを使って少し試してみた。

サンプルアプリケーションの用意

今回は v21 で提供予定の API を使用するため 21.0.0-next.3 を使ってアプリケーションを用意した。

作成するサンプルは SaaS に signup するときのフォームをイメージしており、ユーザー名 / メールアドレス / パスワード / パスワード(確認用) の 4 つの input を配置するものとする。

フォームの作成

今回使用する新しい APIform() という名前である。この API を使って Field 型を作っていく。

Reactive Forms と異なり Signal Form はデータを管理しないという点で今までと考え方を変える必要がある。つまり、データの管理は開発者自身がおこなう。 model をWritableSignal 型で用意し、これを form() の第一引数に渡すことで、フォームでの更新を model に反映させることができる。

というわけで、まずは signup のための interface とその model を定義する。

export interface SignupUser {
  userName: string;
  email: string;
  password: string;
  confirmPassword: string;
}

@Component({ ... })
export class App {
  signupUserModel = signal<SignupUser>({
    userName: '',
    email: '',
    password: '',
    confirmPassword: '',
  });

  form = form(this.signupUserModel);
}

まずはミニマムの準備ができた。これを HTML で input にバインドするためには、新しく提供される Control という class が必要になる。@Component の imports に登録した上で次のように書くとよい。

<form>
  <mat-form-field>
    <mat-label>ユーザー名</mat-label>
    <input [control]="form.userName" matInput />
  </mat-form-field>
</form>

これでユーザー名のフォームに値を入力すると signupUserModel の値が更新されることがわかる。

さて、フォームに重要なのはバリデーションである。次はバリデーションの設定の仕方を見ていくことにする。

バリデーションの設定は form() の第二引数に schema として渡せば良い。そのためには schema を準備する必要がある。 ここでも Signal Form 向けに提供される新しい API を使用する。使う APIschema() となる。model を作ったときと同じ型をジェネリクスとして渡すことで、各プロパティのバリデーションを設定できる。

まず、すべてのプロパティは必須であることを期待するため、schema を次のようにする。

const signupUserSchema = schema<SignupUser>((form) => {
  required(form.userName);
  required(form.email);
  required(form.password);
  required(form.confirmPassword);
});

@Component({ ... })
export class App {
  signupUserModel = signal<SignupUser>({
    userName: '',
    email: '',
    password: '',
    confirmPassword: '',
  });

  form = form(this.signupUserModel, signupUserSchema);
}

エラーメッセージを表示する場合は errors() の種類を確認して、適切な文字列を表示するように HTML を書く。例えば必須エラーは次のようになる。

  <mat-form-field>
    <mat-label>ユーザー名</mat-label>
    <input [control]="form.userName" matInput />
    @if (!form.userName().valid() && form.userName().errors()[0].kind === 'required') {
      <mat-error>ユーザー名は必須です</mat-error>
    }
  </mat-form-field>

上の例ではエラーメッセージをテンプレート内に書いている。 しかし、実際のアプリケーションを作っていると同じバリデーションエラーでもいくつかの場所で表記揺れが起きてくるといった課題もあるのではないだろうか? schema ではこの課題も解決してくれる。それが required() の第二引数の config である。

const signupUserSchema = schema<SignupUser>((form) => {
  required(form.userName, { message: 'userName is required' });
  ...
});
  <mat-form-field>
    <mat-label>ユーザー名</mat-label>
    <input [control]="form.userName" matInput />
    @if (!form.userName().valid() && form.userName().errors()[0].kind === 'required') {
      <mat-error>{{ form.userName().errors()[0].message }}</mat-error>
    }
  </mat-form-field>

かゆいところに手が届く、といった印象を受ける。

最後にカスタムバリデーターについても紹介しておく。 ここではパスワードとパスワード(確認用)が一致しているかどうかをチェックする。

const signupUserSchema = schema<SignupUser>((form) => {
  ...
  validate(form, ({ value }) => {
    if (value().password === value().confirmPassword) {
      return [];
    }
    return customError({
      kind: 'unmatch',
      message: 'パスワードとパスワード(確認用)が一致しません',
    });
  });
});

validate() という API を使うことでクロスフィールドバリデーションも簡単に作ることができる。

このようにシンプルなフォームの実装では Signal Form で問題なく実装することができる。 今回の例では required しか使っていないが、min, max や minLength, maxLength, email, pattern など ReactiveForms で使えていた組み込みバリデーターは v21 で提供される。

まとめ

ここまで見てきたように Signal based な Form が着々と使えるようになってきている。

最初に書いたように experimental であるため、仕事で開発しているアプリケーションに使うことは推奨しない。が、Reactive Forms などで作っているアプリケーションがあれば、どこまで再現可能なのか確認してみるのはよいと思う。

まだまだ試せていない API もあるが、next が終わったときにでもまた触ってみたい。

参考