Angular 22 Signal Formsでモデルが唯一の真実になる設計をレビューする

form() に渡すのは WritableSignal である。@angular/forms 22.2.1 の型定義には、form uses the given model as the source of truth and does not maintain its own copy of the data. と書かれている。ReactiveFormsModule の FormGroup がフォーム側に値を持ち、patchValue と getRawValue で出し入れしていたのに対して、Signal Forms の値はモデルの側にしかない。

この1点から、レビューで確かめる項目がいくつか決まる。値は1箇所にあるが、dirty や touched のような利用者の操作の記録はフォーム側にあり、両者は同じ経路で更新されない。

根と子のvalueの正体

form(model) が作るのは FieldTree で、その根のフィールドの value は渡したモデルそのものである。実装では、根の構造体(RootFieldNodeStructure)が value に引数をそのまま保持している。

子のフィールドは違う。親の value と自分のキーから組み立てた派生の signal になる。

子フィールドのvalueを作る関数
function deepSignal(source, prop) {
  const read = computed(() => source()[prop()]);
  read.set = (value) => {
    if (Object.is(untracked(read), value)) {
      return;
    }
    source.update((current) => valueForWrite(current, value, prop()));
  };
  // update は read.set(fn(untracked(read))) に落ちる
  return read;
}

valueForWrite は配列なら複製して添字を差し替え、オブジェクトならスプレッドで新しいオブジェクトを作る。つまり1つのフィールドへの書き込みは、そのフィールドから根までのすべての階層に新しい参照を作り、最後に根のモデルの set へ届く。フォーム側に値の控えは無い。

ここから2つの性質が出る。1つは、モデルを読んでいる computed がどのフィールドの変更でも再計算されること。根の参照が毎回変わるためである。もう1つは、同じ値の書き込みが Object.is で捨てられることで、set を呼んだのに update が走らない経路がある。後者は値が変わらないので問題になりにくいが、書き込みの回数を数えている実装では前提が崩れる。

モデルを別経路から書き換えたとき

値の入口は2つある。利用者が入力欄へ入力する経路と、コードがモデルの signal へ書き込む経路である。この2つは別の記録を残す。

dirty が立つのは前者だけである。FieldNode は controlValue という signal を value の上に重ねて持ち、その set の中で markAsDirty() を呼んでいる。

controlValueの定義
controlValueSignal() {
  const controlValue = linkedSignal(this.value);
  controlValue.rawSet = controlValue.set;
  controlValue.set = (newValue) => {
    controlValue.rawSet(newValue);
    this.markAsDirty();
    this.debounceSync();
  };
  // update も同じ2行を通る
  return controlValue;
}

[formField] ディレクティブが書き込むのは controlValue のほうで、value へは debounceSync() を経て反映される。コードからモデルを set した場合はこの関数を通らないので、dirty も touched も変わらない。値だけが入れ替わる。

下書きの復元
restoreDraft(draft: OrderForm): void {
  this.model.set(draft);
@Reviewer
値は入りますが `dirty` は false のままです。「未保存の変更があります」の表示を `dirty` で出しているなら、復元した下書きは警告の対象から漏れます。
}

この性質そのものは設計どおりである。初期値の投入や下書きの復元を利用者の入力として数えたくはない。確かめるのは、dirty を何の判定に使っているかである。離脱の確認に使っているなら、コードからの復元は漏れる。送信ボタンの活性に使っているなら、復元した値を送れなくなる。

もう1つ、同じ書き込みが起こす副作用がある。FieldNode は pendingSync を linkedSignal で持ち、source にフィールドの value を置いている。

打ちかけの同期を中断させる仕組み
pendingSync = linkedSignal({
  source: () => this.value(),
  computation: (_source, previous) => {
    previous?.value?.abort();
    return undefined;
  },
});

debounce() を設定したフィールドで利用者が入力している最中にモデルを書き換えると、value が変わったことで前回の AbortController が abort される。打ちかけの入力はモデルへ届かず、表示だけが新しい値に入れ替わる。外から値を差し込む処理と debounce を同じフィールドで使う構成では、どちらが勝つかを決めておく必要がある。

resetが戻すもの

FieldState.reset() の型定義には、Note this does not change the data model, which can be reset directly if desired. と書かれている。引数を省いた場合に戻るのは touched と dirty だけで、値はそのまま残る。

実装の順序
_reset(value) {
  this.pendingSync()?.abort();
  if (value !== undefined) {
    this.value.set(value);
  }
  this.controlValue.rawSet(this.value());
  this.nodeState.markAsUntouched();
  this.nodeState.markAsPristine();
  // 子フィールドへも再帰する
}

値も初期状態へ戻したいなら、reset(initialValue) と渡すか、モデルの signal を直接 set してから reset() を呼ぶ。初期値をどこに置くかはここで決まる。フォームの外に初期値の定数を持ち、モデルの作成と reset の両方がそれを参照する形なら、定義は1箇所で済む。

Comment
@Reviewer: 送信後の `reset()` では値が残ります。ここは入力欄を空に戻す意図だと思いますので、`reset(emptyOrder)` のように初期値を渡してください。`emptyOrder` は `signal(emptyOrder)` の初期化でも使っている定数をそのまま渡せます。

submitが既に持っている状態

送信の処理を自前で組む前に、submit() が何をしているかを読む。@angular/forms の実装は次の順で動く。

  1. すでに送信中なら、何もせず false を返す
  2. action が無ければ NG1915 で例外になる
  3. フォーム全体を markAsTouched() する
  4. 検証の状態を見て、action を実行するか onInvalid を呼ぶかを決める
  5. action の戻り値がエラーなら、フィールドごとに割り振る
  6. finally で送信中の状態を下ろす

1つ目で再入が止まるため、二重送信を防ぐための独自のフラグは要らない。送信中かどうかは submitting() で読める。3つ目があるので、送信を押したときにエラーを表示させるための markAsTouched() の呼び出しも重複する。

4つ目の判定だけは、素直に読むと意外な形をしている。

actionを実行するかの判定
function shouldRunAction(node, ignoreValidators) {
  switch (ignoreValidators) {
    case 'all': return true;
    case 'none': return untracked(node.valid);
    default: return !untracked(node.invalid);
  }
}

既定は !invalid である。そして valid() は !invalid() と同じではない。型定義のコメントに例が書かれていて、3つの検証のうち2つがエラー無し、1つが未解決のとき、valid() は false(未解決があるため)で invalid() も false(エラーが無いため)になる。

したがって非同期の検証が終わる前に送信を押すと、既定では action が走る。重複チェックの通信が返る前に送信できてしまう構成では、ignoreValidators: 'none' を指定して valid() を要求するか、pending() を見てボタンを無効にする。どちらを選んだかは差分に現れるので、指定が無いものは既定のままだと読める。

Comment
@Reviewer: このフォームはメールアドレスの重複を `validateHttp` で確かめていますが、`submit()` の `ignoreValidators` が未指定なので既定の `!invalid()` が使われます。通信が返る前に押されたときは重複のまま送信されます。`ignoreValidators: 'none'` を付けるか、ボタンの `disabled` に `pending()` を含めてください。

送信エラーの寿命

action が返したエラーは、フィールドの submissionErrors に入る。この signal も linkedSignal で、source に値が置かれている。

送信エラーの保持
submissionErrors = linkedSignal({
  source: this.node.structure.value,
  computation: () => [],
});

値が変わればエラーは空配列に戻る。サーバーが返した「この注文番号は使えません」のようなエラーは、利用者がそのフィールドを1文字直した時点で消える。再送信するまでエラーを出し続ける実装にはなっていない。

利用者の操作としては自然である。確かめるのは、消えてよいエラーだけがここに入っているかである。「在庫が足りません」のようにフィールドの値と関係しない理由を submissionErrors に入れると、関係のない別のフィールドを触ったときに消える。値に紐づかないエラーは、フォームの外の signal に持つほうが寿命が合う。

テンプレート側のバインド

ディレクティブのセレクタは [formField] である。入力の別名も formField で、exportAs も同じ名前になっている。

フィールドのバインド
<form [formRoot]="orderForm">
  <input [formField]="orderForm.orderId" />
  @if (orderForm.orderId().touched() && orderForm.orderId().invalid()) {
    @let error = orderForm.orderId().errors()[0];
    <p></p>
  }
</form>

form[formRoot] は <form> 要素だけに付くディレクティブで、novalidate を設定してブラウザの検証を止め、submit イベントの既定動作を止めて submit() を呼ぶ。フォームの送信オプション(FormOptions の submission)を form() へ渡していれば、テンプレート側に (ngSubmit) を書く必要は無い。

エラーの表示では errors() と errorSummary() の違いを見る。前者はそのフィールド自身のエラーだけ、後者は子孫のエラーも含む。入力欄の下に出すなら前者、フォームの先頭にまとめて出すなら後者になる。

状態を制御する関数には書き方の変更が1つある。disabled() に関数や文字列を直接渡す形は非推奨で、{ when: fn } の形になった。

無効化の指定
form(this.model, (order) => {
  disabled(order.shippingDate, { when: ({ valueOf }) => valueOf(order.pickup) });
  hidden(order.shippingAddress, ({ valueOf }) => valueOf(order.pickup));
});

hidden() にはもう一段注意がある。型定義のコメントに Note: This doesn't hide the field in the template, that must be done manually. と書かれている。隠れるのは検証と touched / dirty の集計からだけで、DOM は @if で自分で消す。隠したつもりのフィールドが画面に残り、しかも検証されない状態になりうるので、hidden() を使っている箇所はテンプレート側の @if と対で読む。

レビュー観点チェックリスト

Signal Formsのモデルと状態を確かめる項目
  • form() に渡しているモデルが WritableSignal で、所有者が1箇所に決まっているか
  • モデルを form() の外から set している箇所が、dirty と touched を更新しない前提で書かれているか
  • dirty を離脱の確認や送信ボタンの活性に使っている場合、コードからの値の投入が漏れないか
  • debounce() を設定したフィールドへ、外から値を差し込む処理が重なっていないか
  • reset() で値も戻す意図なら、引数に初期値を渡しているか
  • 初期値の定義が、モデルの初期化と reset の2箇所に分かれていないか
  • 送信中のフラグを自前で持っていないか(submitting() がある)
  • 送信時の markAsTouched() を自分で呼んでいないか(submit() が呼ぶ)
  • 非同期の検証があるフォームで、ignoreValidators の指定か pending() の確認があるか
  • valid() を !invalid() として扱っている判定が無いか
  • 値と関係しないエラーを submissionErrors に入れていないか
  • テンプレートのバインドが [formField] になっているか
  • hidden() を使っているフィールドが、テンプレート側でも @if で消えているか
  • disabled() に関数を直接渡す非推奨の形が残っていないか

おわりに

ReactiveFormsModule では、フォームが値を持ち、モデルとの同期をコードで書いていた。Signal Forms はその同期を無くす代わりに、値と操作の記録を別の場所に置いた。モデルを直接書き換える経路が常に開いているため、dirty や touched を判定に使う箇所は、その経路を通る更新があるかどうかで意味が変わる。

レビューの順序としては、モデルの set を呼んでいる箇所を先に数え、それぞれが利用者の入力に相当するかどうかを決める。そのあとで dirty を読んでいる箇所と突き合わせる。フォームの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「Signal Forms」の群に並べてある。