AngularのlinkedSignalがeffectによるリセット実装を置き換える場面

一覧の絞り込みを変えたら選択を外す、タブを切り替えたら入力中の値を捨てる。こうした「元が変わったら派生側を捨てる」処理は、effect でも linkedSignal でも書ける。どちらを選ぶかの判断はAngularのeffectを状態同期に使うコードをレビューで止めるにまとめた。

ここで扱うのは、選んだあとに残る問題である。linkedSignal のリセットがいつ起きるのか、何がその契機になるのかは、effect の実行時期とは別の仕組みで決まっている。両者を取り替えると、値が置き換わるタイミングと、置き換わる条件の両方が変わる。

読んだ時点で決まる値

linkedSignal の中身は、computed と同じ遅延評価のノードである。@angular/core 22.2.1 の LINKED_SIGNAL_NODE には、再計算が要るかどうかの判定が2つ入っている。

ノードの判定部分
producerMustRecompute(node) {
  return node.value === UNSET || node.value === COMPUTING;
},
producerRecomputeValue(node) {
  const oldValue = node.value;
  node.value = COMPUTING;
  // source() と computation() を依存の記録つきで実行する
}

set() を呼んだときの経路はこうなっている。

書き込みの実装
function linkedSignalSetFn(node, newValue) {
  producerUpdateValueVersion(node);
  signalSetFn(node, newValue);
  producerMarkClean(node);
}

書いた直後にノードを clean として記録するため、以降の読み取りは書いた値をそのまま返す。source に使っている signal が更新されるとノードが dirty になり、次に読まれた時点で producerRecomputeValue が走って、書いた値が計算結果に置き換わる。

置き換えが起きるのは読まれた時点であり、source が更新された時点ではない。誰も読まなければ計算も走らない。この性質から、linkedSignal を読む側が常に整合した組み合わせを見ることになる。

effectで書いたリセットとの差

effect で同じことを書いた場合、置き換えは別の時期に起きる。コンポーネントの中で作った effect はそのビューに属し、変更検知の中で実行される。実行の位置は refreshView の中で決まっている。

refreshViewの実行順
if (templateFn !== null) {
  executeTemplate(tView, lView, templateFn, 2, context);
}
// preOrderHooks の実行
runEffectsInView(lView);
detectChangesInEmbeddedViews(lView, 0);

テンプレートの評価が先で、ビューの effect は後である。絞り込みが変わった回の変更検知では、テンプレートが「新しい一覧と古い選択」の組み合わせで1度評価される。そのあと effect が走って選択を外し、signal への書き込みがビューを dirty にして、もう1度評価される。

effectで選択を外す実装
constructor() {
  effect(() => {
    this.visibleProducts();
    this.selectedId.set(null);
@Reviewer
この書き込みはテンプレートの評価より後に走るため、絞り込みを変えた回は古い選択のまま1度描画されます。`linkedSignal` に寄せると、読んだ時点で計算されるためこの1回が無くなります。
}); }

1度目の評価で DOM と子コンポーネントの入力が古い選択で更新され、2度目で正しい値に直る。利用者の目に触れるかは描画の時期によるが、確実に差が出るのは子コンポーネント側である。入力が2回設定されるため、入力の変化を契機にした処理(ngOnChanges、入力を source にした別の linkedSignal、入力を読む effect)が、存在しない組み合わせに対して1回動く。

linkedSignal ではこの回が無い。テンプレートが値を読んだ時点で計算が走るので、古い選択と新しい一覧の組み合わせは観測できない。

もう1つ、構造上の違いがある。effect は注入コンテキストを要求し(開発モードで assertInInjectionContext)、DestroyRef に後始末を登録する。linkedSignal はどちらも持たない。サービスのフィールドにも、関数の中にも置ける。寿命の管理が要らないぶん、置き場所の選択肢は広い。

短い形で書いたときのsourceの範囲

linkedSignal には2つの呼び方があり、実装では引数の型で振り分けている。

2つの形の受け取り方
const identityFn = (v) => v;

function linkedSignal(optionsOrComputation, options) {
  if (typeof optionsOrComputation === 'function') {
    const getter = createLinkedSignal(optionsOrComputation, identityFn, options?.equal);
    // ...
  } else {
    const getter = createLinkedSignal(
      optionsOrComputation.source, optionsOrComputation.computation, optionsOrComputation.equal);
    // ...
  }
}

関数を1つ渡す短い形では、その関数がまるごと source になり、computation は恒等関数になる。つまり式の中で読んだすべての signal がリセットの契機である。

短い形で書いた既定の選択
readonly selectedId = linkedSignal(
  () => this.visibleProducts()[0]?.id ?? null,
);

この書き方では visibleProducts() の更新でリセットされる。意図どおりである。問題は、式が育ったときに起きる。

表示名まで式に入れた場合
readonly selectedLabel = linkedSignal(
  () => `${this.locale()} / ${this.visibleProducts()[0]?.name ?? ''}`,
@Reviewer
`locale()` も source に含まれるため、言語を切り替えるだけで利用者の選択が捨てられます。リセットの契機を `visibleProducts` だけにするなら、`source` と `computation` を分けて書いてください。
);

source と computation を分ける形には、契機を明示するという役割がある。2行で書ける処理をわざわざ分けるのは、分けた側だけが「何でリセットするか」をコードに残せるためである。

computationの中の読み取り

分けて書いた場合でも、契機は source に限られない。producerRecomputeValue は source() と computation() の両方を同じ依存の記録の中で実行する。

記録の範囲
const prevConsumer = consumerBeforeComputation(node);
try {
  const newSourceValue = node.source();
  // ...
  newValue = node.computation(newSourceValue, prev);
  node.sourceValue = newSourceValue;
  setActiveConsumer(null);
  // ...
} finally {
  consumerAfterComputation(node, prevConsumer);
}

型定義のコメントにも The computation is reactive, meaning the linked signal will automatically update whenever any of the signals used within the computation change. と書かれている。computation の中で別の signal を読めば、その signal もリセットの契機になる。

computationが別のsignalを読んでいる
readonly selectedId = linkedSignal<Product[], string | null>({
  source: () => this.visibleProducts(),
  computation: (products) => {
    const favorite = this.favoriteId(); // ここも契機になる
    return products.find((p) => p.id === favorite)?.id ?? products[0]?.id ?? null;
  },
});

お気に入りを別の画面で変更して戻ってきたとき、一覧が同じままでも選択はリセットされる。意図した挙動ならよいが、source に書いていないものが契機になっていることは読んだだけでは分からない。契機に含めたくない読み取りは untracked() で囲む。

Comment
@Reviewer: `computation` の中の `favoriteId()` も依存として記録されるため、お気に入りの変更でも選択がリセットされます。既定値の決定にだけ使う値であれば `untracked(() => this.favoriteId())` で読んでください。

previousのsourceとvalue

分けて書いた形では、computation が第2引数に previous を受け取る。中身は前回の source の値と前回の自分の値の組である。

previousの組み立て
const oldValueValid = oldValue !== UNSET && oldValue !== ERRORED;
const prev = oldValueValid ? { source: node.sourceValue, value: oldValue } : undefined;
newValue = node.computation(newSourceValue, prev);

previous が undefined になるのは2つの場合である。1つは初回、もう1つは前回の計算が例外を投げた場合である。previous?.value のように省略可能として扱う必要がある。

previous.value だけを見る実装と、previous.source も見る実装では、保てる条件が違う。

前の選択を引き継ぐ判定
readonly selectedId = linkedSignal<Product[], string | null>({
  source: () => this.visibleProducts(),
  computation: (products, previous) => {
    if (previous?.value && products.some((p) => p.id === previous.value)) {
      return previous.value; // 新しい一覧にも残っているなら維持する
    }
    return products[0]?.id ?? null;
  },
});

これは「選択が新しい一覧に含まれるなら維持する」という条件である。previous.source が要るのは、一覧の変わり方で扱いを分けたい場合である。たとえば並び順が変わっただけなら維持し、絞り込みの条件が変わったなら捨てる、という判定は、前後の source を比べなければ書けない。

source にオブジェクトや配列を渡す場合は、参照の変わり方も見る。毎回新しい配列を返す computed を source にすると、中身が同じでも版が上がり、そのたびに computation が走る。previous.value で維持する条件を書いていれば結果は変わらないが、computation の実行回数は増える。

equalが置き換えを止める

equal は derived 側の値、つまり linkedSignal 自身が持つ値に対する比較である。再計算の結果が前回と等しいと判定されると、新しい値は捨てられ、版も上がらない。

等価と判定したときの処理
wasEqual = oldValueValid && newValue !== ERRORED && node.equal(oldValue, newValue);
// ...
if (wasEqual) {
  node.value = oldValue;
  return;
}
node.value = newValue;
node.version++;

既定は Object.is である。選択の識別子のような原始値なら期待どおりに働くが、オブジェクトを持つ linkedSignal では毎回別の参照になるため、中身が同じでも版が上がる。下流の computed と effect が無駄に走る。

逆に equal を緩めすぎると、リセットが起きたのに下流へ伝わらない。たとえば識別子だけを比べる equal を指定し、同じ識別子で中身の違うオブジェクトを計算した場合、置き換えは起きず前回のオブジェクトが残る。equal は比較の粒度を決める指定であり、リセットの粒度もそこで決まる。

例外を記憶したあとのsetとupdate

computation が例外を投げると、ノードはその例外を記憶する。読むたびに同じ例外が投げ直される。computed と同じ挙動だが、linkedSignal には書き込みがあるため、そこから抜ける経路が2つに分かれる。

更新の実装
function linkedSignalUpdateFn(node, updater) {
  producerUpdateValueVersion(node);
  if (node.value === ERRORED) {
    throw node.error;
  }
  signalUpdateFn(node, updater);
  producerMarkClean(node);
}

update() は記憶した例外を確認して投げ直す。前の値を読めないのだから、更新関数を適用できない。一方 set() の実装(前掲)にはこの確認が無く、記憶した値を上書きする。例外を記憶した状態から set() では復帰でき、update() では復帰できない。

例外が出る computation を書かないのが前提ではあるが、source の形が崩れたときに添字の参照で落ちる経路は作りやすい。computation の中では、source が空である場合と要素が足りない場合を先に扱う。

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

linkedSignalのリセットを確かめる項目
  • effect の中が別の signal への set だけになっていないか。なっていれば linkedSignal で書けるか確かめたか
  • 短い形(関数1つ)で書いた式に、リセットの契機にしたくない signal が混ざっていないか
  • computation の中で読んでいる signal が、リセットの契機として意図したものか。違えば untracked() で囲んでいるか
  • previous を undefined の場合込みで扱っているか(初回と、前回が例外だった場合)
  • 前の選択を維持する条件が、previous.value だけで足りるか。一覧の変わり方で分けるなら previous.source も見ているか
  • source に毎回新しい参照を返す式を渡していないか
  • オブジェクトを持つ linkedSignal で、equal の粒度とリセットの粒度が合っているか
  • computation が例外を投げうる形になっていないか。source が空の場合を先に扱っているか
  • 書き込みのない用途で linkedSignal を使っていないか(computed で足りる)

おわりに

effect で書いたリセットは、変更検知の中でテンプレートの評価より後に走る。linkedSignal のリセットは、値が読まれた時点に走る。この差は、1回だけ存在する中間の組み合わせとして現れ、子コンポーネントの入力を通って伝わる。

置き換えの契機は source だけで決まらない。computation の中の読み取りも、短い形で書いた式の全体も、同じように契機になる。リセットの条件をコードから読み取れるかどうかは、source と computation を分けて書いたかどうかで変わる。Signal の派生と更新の観点はAngular 22のSignalと非同期状態をレビューする100観点の「linkedSignal による上書きとリセット」と「effect の置きどころ」に並べてある。