Angularのmodel()による双方向バインドで値の持ち主が二重になる構造

子コンポーネントに値を渡し、子が変えた値を親へ戻す。この往復は input() と output() の組でも書けるが、model() を使うと1行で済む。

2つの書き方
// input と output の組
readonly quantity = input.required<number>();
readonly quantityChange = output<number>();

// model
readonly quantity = model.required<number>();

下の書き方では、親のテンプレートに [(quantity)] と書けば往復が閉じる。記述量が減るだけでなく、入力名と出力名がずれる心配もなくなる。

減るのは記述量であって、持ち主の数ではない。@angular/core 22.2.1 の実装を読むと、model() は子の側に書き込める signal を作る。親も同じ値を signal で持つので、同じ値を指す書き込み可能な入れ物が2つになる。食い違いが表に出るかどうかは、親が受け取った値をそのまま持つかどうかで決まる。

親からの書き込みと子からの書き込みが通る経路

model() の実体は createModelSignal である。

22.2.1 の createModelSignal(抜粋)
function createModelSignal(initialValue, opts) {
  const node = Object.create(INPUT_SIGNAL_NODE);
  const emitterRef = new OutputEmitterRef();
  node.value = initialValue;
  function getter() {
    producerAccessed(node);
    assertModelSet(node.value);
    return node.value;
  }
  getter[SIGNAL] = node;
  getter.asReadonly = signalAsReadonlyFn.bind(getter);
  getter.set = newValue => {
    if (!node.equal(node.value, newValue)) {
      signalSetFn(node, newValue);
      emitterRef.emit(newValue);
    }
  };
  getter.update = updateFn => {
    assertModelSet(node.value);
    getter.set(updateFn(node.value));
  };
  getter.subscribe = emitterRef.subscribe.bind(emitterRef);
  getter.destroyRef = emitterRef.destroyRef;
  // ...
}

出力へ値が出るのは getter.set の中だけである。update も set を呼ぶので同じ経路を通る。つまり子が自分で書き換えたときには、必ず出力が発行される。

親からの書き込みは別の経路を通る。束縛された入力の書き込みは writeToDirectiveInput から applyValueToInputField へ進み、signal ベースの入力ならノードに付いた関数を呼ぶ。

22.2.1 の INPUT_SIGNAL_NODE
const INPUT_SIGNAL_NODE = /* @__PURE__ */(() => {
  return {
    ...SIGNAL_NODE,
    transformFn: undefined,
    applyValueToInputSignal(node, value) {
      signalSetFn(node, value);
    }
  };
})();

signalSetFn を直に呼ぶので、getter.set は通らない。親が書いた値で出力が発行されることはなく、往復が無限に続くこともない。

この非対称は、(quantityChange) に書いた処理がいつ走るかを決める。走るのは子が書き換えたときだけで、親が自分の signal を更新したときには走らない。親の側で値の変化を1箇所にまとめたいなら、ハンドラではなく親の signal を読む computed か effect に置く。

Comment
@Reviewer: `(quantityChange)` で在庫の再計算を呼んでいますが、この出力は子が `set` を呼んだときだけ発行されます。親が `quantity.set()` で値を戻す経路では走らないため、再計算が片側だけ抜けます。再計算は `quantity` を読む `computed` に移してください。

親が値を正規化して返したときに残る差

[(quantity)] と書いている間は、親は子が渡した値をそのまま持つ。正規化や上限の判定を挟みたくなると、束縛を2つに割ることになる。

上限で丸めて返す親
@Component({
  template: `<app-quantity-field
    [quantity]="quantity()"
    (quantityChange)="onQuantityChange($event)" />`,
})
export class OrderLine {
  readonly quantity = signal(100);
  readonly stock = signal(100);

  onQuantityChange(value: number): void {
    this.quantity.set(Math.min(value, this.stock()));
  }
}

在庫が100で quantity も100のとき、子で150が入力されると次の順に進む。子の set(150) がノードを150にして150を emit する。親のハンドラが Math.min(150, 100) を set に渡すが、Object.is(100, 100) なので親の signal は変化しない。

残るのは、親から子へ150を戻す経路である。ここを決めるのは ɵɵproperty の中の bindingUpdated で、束縛のスロットに前回書いた値が残っている。スロットは100、今回の値も100なので、Object.is が真になって書き込みは起きない。bindingUpdated の判定そのものはAngularのinput.requiredと既定値で埋めた入力のどちらを選ぶかで読んだとおりである。

結果として、子は150を持ち、親は100を持つ。画面には子が持つ150が出ていて、注文として送られるのは親の100になる。親が「受け取らなかった」ことを子へ伝える経路が、束縛の側に無い。

丸めた値が前回と違えば差は消える。在庫が100で quantity が80のときに150が入れば、親は100を持ち、スロットの80と違うので100が子へ書き戻される。差が残るのは、親が返す値が前回と同じになったときだけであり、入力の上限に貼り付いた状態がまさにそれにあたる。レビューで再現手順を1つ挙げるなら、上限に達した状態からさらに大きい値を入れる操作になる。

Comment
@Reviewer: `(quantityChange)` で在庫上限に丸めていますが、丸めた結果が前回と同じ値になる場合、`[quantity]` の束縛は変化しないため子へ戻りません。上限に達した状態でさらに大きい値を入れると、子が持つ値と親が持つ値が食い違います。子の `model()` を `input()` にして、値を親だけが持つ形にしてください。

中身を書き換えた更新が出力に出ない条件

getter.set の判定は node.equal である。INPUT_SIGNAL_NODE は SIGNAL_NODE を広げたものなので、ここに入るのは既定の比較関数である。

22.2.1 の SIGNAL_NODE と defaultEquals
const SIGNAL_NODE = /* @__PURE__ */(() => {
  return {
    ...REACTIVE_NODE,
    equal: defaultEquals,
    value: undefined,
    kind: 'signal'
  };
})();

function defaultEquals(a, b) {
  return Object.is(a, b);
}

オブジェクトや配列を model() で持たせ、中身だけ書き換えて同じ参照を戻すと、Object.is が真になって set の中身は走らない。ノードの値も変わらず、出力も発行されない。

出力が発行されない更新
readonly filter = model.required<{ keyword: string; tags: string[] }>();

addTag(tag: string): void {
  this.filter.update(value => {
    value.tags.push(tag);   // 同じ参照
    return value;
  });
}

同じ参照を親も持っているため、親の側から読んだ tags にも tag は入っている。画面の表示だけは合うので、気付く契機は親が (filterChange) に置いた保存処理が走らないことだけになる。新しいオブジェクトを作って渡せば、参照が変わって出力が発行される。

signal() なら equal を渡して比較の仕方を変えられるが、model() には渡す口がない。

22.2.1 の ModelOptions
interface ModelOptions {
    alias?: string;
    debugName?: string;
}

alias と debugName の2つだけである。input() が持つ transform も無いので、親から来た値を子の側で整える余地もない。model() で受けた値の形を変える必要が出た時点で、input() と output() の組に戻す判断になる。

親が受け取りを拒めない構造

出力の発行は OutputEmitterRef.emit である。ここで購読側が投げた例外は、呼び出し元へ戻らない。

22.2.1 の OutputEmitterRef.emit(抜粋)
emit(value) {
  if (this.destroyed) {
    console.warn(formatRuntimeError(953, ngDevMode && 'Unexpected emit for destroyed `OutputRef`. ' + 'The owning directive/component is destroyed.'));
    return;
  }
  if (this.listeners === null) {
    return;
  }
  this.isEmitting = true;
  const previousConsumer = setActiveConsumer(null);
  try {
    for (const listenerFn of this.listeners) {
      try {
        if (listenerFn !== null) {
          listenerFn(value);
        }
      } catch (err) {
        this.errorHandler?.handleError(err);
      }
    }
  } finally {
    // ...
  }
}

購読ごとに try で囲み、例外は ErrorHandler へ渡して次の購読へ進む。しかも emit は signalSetFn の後に呼ばれるので、親のハンドラが落ちた時点で子の値はもう新しいものになっている。親の検証で例外を投げても、子の表示は変わらないまま進む。

破棄済みのコンポーネントから emit が呼ばれた場合も同じ性質がある。console.warn を出して戻るだけで、例外にはならない。しかもメッセージの組み立ては ngDevMode の中にあるので、本番ビルドで記録に残るのは NG0953 という番号だけである。開発時の警告だけが手がかりで本番では番号しか残らない形は、このシリーズで繰り返し出てくる。

したがって「親が値を検証して弾く」という設計は、model() の上では成り立たない。弾いた結果を子へ伝える手段が、前節で見た差し戻しだけであり、それが止まる条件もそこにある。検証が要るなら、値を親だけが持つ形にする。

親側の束縛先が書き込める signal かどうか

差し戻しの経路は、テンプレートの生成コードにも現れる。[(x)] の書き込み側は @angular/compiler が次のように組む。

22.2.1 の transformTwoWayBindingSet(抜粋)
if (target instanceof ReadPropExpr || target instanceof ReadKeyExpr) {
  return twoWayBindingSet(target, value).or(target.set(value));
}
if (target instanceof ReadVariableExpr) {
  return twoWayBindingSet(target, value);
}
throw new Error(`Unsupported expression in two-way action binding.`);

前段の twoWayBindingSet が偽を返したときだけ、後段の代入が走る。実行時の判定は書き込めるかどうかだけを見る。

22.2.1 の ɵɵtwoWayBindingSet と isWritableSignal
function ɵɵtwoWayBindingSet(target, value) {
  const canWrite = isWritableSignal(target);
  canWrite && target.set(value);
  return canWrite;
}

function isWritableSignal(value) {
  return isSignal(value) && typeof value.set === 'function';
}

束縛先が computed() や asReadonly() の結果なら set を持たないので、後段の代入が走ってフィールドそのものが生の値で置き換わる。signal だったフィールドが関数でなくなるので、以後の読み取りが壊れる。

この経路は、テンプレートの型検査が通れば踏まない。双方向の束縛先は検査用のコードで ɵunwrapWritableSignal に包まれ、この関数は書き込める signal の印を持つ型か、signal でない値しか受け取らない。

22.2.1 の宣言
declare const ɵWRITABLE_SIGNAL: unique symbol;

interface WritableSignal<T> extends Signal<T> {
    [ɵWRITABLE_SIGNAL]: T;
    set(value: T): void;
    // ...
}

declare function ɵunwrapWritableSignal<T>(value: T | {
    [ɵWRITABLE_SIGNAL]: T;
}): T;

印は WritableSignal にだけ付いているので、読み取り専用の signal を [(x)] に渡した差分はビルドで止まる。逆に言えば、テンプレートの型検査が届かない箇所、たとえば strictTemplates を切った構成や、テンプレートを文字列として組み立てている箇所では、この保護が無い。

値の持ち主の決め方

ここまでの機構から、model() を選ぶ条件は次のように引ける。

子が値の持ち主でよいなら model() が合う。開閉の状態、タブの選択、ページャーの現在位置のように、親が受け取った値を加工せずそのまま持つ場合である。親は [(x)] だけを書き、受け取った値に手を入れない。

親が値の持ち主なら input() と output() の組にする。親が範囲を丸める、他の状態と突き合わせて弾く、サーバーへ送って結果で確定する、のいずれかが入る場合がこれにあたる。子は input() を読んで描くだけで、自分の複製を持たない。持ち主が1つなので、前節までの食い違いはそもそも起きない。

この形でも、ネイティブの <input> に入力した文字はブラウザ側に残る。親が値を弾いても要素の value は戻らないので、要素への書き込みは別に書く。これは signal の話ではなく DOM の層の話で、model() を外しても残る。

どちらでもない書き方として、model() で受けた値を子の signal に写す形がある。model() 自体が書き込める入れ物なので、写した先は3つめの持ち主になる。入力を内部へ写した更新が届かなくなる経路はAngularのinput.requiredと既定値で埋めた入力のどちらを選ぶかで扱った。

なお model.required は、束縛が来る前に読むと NG0952 になる。assertModelSet が getter と update の先頭に入っているためで、input.required の NG0950 と同じ理由で、読む位置はコンストラクターやフィールドの初期化子の外になる。

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

model() の持ち主を確かめる項目
  • 親が受け取った値を加工せずそのまま持っているか(丸める、弾く、置き換えるが無いか)
  • [(x)] を [x] と (xChange) に割っている箇所で、親が返す値が前回と同じになる入力が無いか
  • (xChange) に置いた処理が、親からの更新では走らない前提で書かれているか
  • オブジェクトや配列の model() を、同じ参照を返す update で書き換えていないか
  • model() で受けた値を子の signal へ写していないか
  • 親のハンドラで投げた例外を、子を止める手段として使っていないか
  • 双方向の束縛先が WritableSignal になっているか(computed や asReadonly() の結果でないか)
  • テンプレートの型検査が届く構成か(届かない箇所では束縛先の検査が無い)
  • model.required を読む位置が、束縛が来たあとになっているか
  • 値の形を整える必要が出た箇所で、model() のまま済ませようとしていないか

おわりに

model() は、子の側に書き込める signal と出力の組を作る。出力が発行されるのは set が呼ばれたときだけで、親からの書き込みは signalSetFn を直に呼ぶため発行されない。往復が止まるのはこの非対称のおかげである。

同じ非対称が、親が値を加工したときに差を残す。親が返す値が前回の束縛と同じなら bindingUpdated が差し戻しを止め、子は自分が書いた値を持ち続ける。親のハンドラで例外を投げても子の値は戻らないので、親が受け取りを拒む設計は成り立たない。

したがって model() を選ぶかどうかは、書く量ではなく、どちら側が値を持つかで決まる。親が加工するなら持ち主は親であり、子は input() を読んで描くだけにする。model() が合うのは、親が子の決めた値をそのまま受け取る場合に限られる。

入出力契約の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「入出力契約」の群に並べてある。