Angularのeffectを状態同期に使うコードをレビューで止める

Signal を使い始めた実装でよく見るのが、effect() の中で別の signal を set している形である。依存が変わったら自動で走るので、状態の同期手段として使いたくなる。

Angular の公式ドキュメントも effect() を副作用のためのAPIと位置づけている。ここで言う副作用とは、ログ出力、ストレージへの保存、リアクティブでないライブラリへの反映といった、Signal の外側へ変更を届ける処理を指す。状態から状態を作る処理は含まない。

effectで同期している実装

次のコードは、フィルタ条件が変わったら一覧を絞り込んでいる。

effectで状態を同期している実装
export class ProductListComponent {
  readonly allProducts = signal<Product[]>([]);
  readonly keyword = signal('');
  readonly visibleProducts = signal<Product[]>([]);

  constructor() {
    effect(() => {
      const keyword = this.keyword();
      this.visibleProducts.set(
        this.allProducts().filter(p => p.name.includes(keyword)),
      );
@Reviewer
この `effect` は他の signal を `set` しているだけです。`computed` にすれば依存が式に現れ、初期化の順序を気にする必要もなくなります。
}); } }

動作としては期待どおりになる。それでも computed() に書き換えたほうがよい理由が2つある。

1つは依存の読み取りやすさである。computed なら「visibleProductsallProductskeyword から決まる」という関係が式そのものに現れる。effect の場合、関係を知るには本体を読んで set の対象を確認しなければならない。同期先が複数あればなおさら追いにくくなる。

もう1つは初期状態の扱いである。effect で同期する形では、visibleProducts に初期値を別途与える必要がある。effect が最初に走るまでの間、allProducts の中身と visibleProducts は一致しない。computed にはこの空白がない。

computedへ書き換えた実装
export class ProductListComponent {
  readonly allProducts = signal<Product[]>([]);
  readonly keyword = signal('');

  readonly visibleProducts = computed(() =>
    this.allProducts().filter(p => p.name.includes(this.keyword())),
  );
}
Comment
@Reviewer: この `effect` は他の signal を `set` するだけの処理です。`computed` にすると依存が式に現れ、初期値を別に用意する必要もなくなります。

派生と手動更新を両立させたい場合

computed へ寄せられない要件もある。派生値でありながら、利用者が手で上書きできる状態がそれにあたる。

一覧の先頭を既定の選択とし、利用者が別の行を選んだらそれを保持したい、という要件を考える。computed は読み取り専用なので set できない。かといって effect で同期すると、利用者の選択を上書きしてしまう。

このために linkedSignal() がある。

linkedSignalで派生と手動更新を両立する
readonly selectedProduct = linkedSignal(() => this.visibleProducts()[0] ?? null);

onSelect(product: Product): void {
  this.selectedProduct.set(product);
}

linkedSignal は、依存元が変わったときに計算し直され、それ以外のときは set された値を保持する writable な signal である。

依存元が変わっても前の選択を維持したい場合は、sourcecomputation を分けて書く形にすると、previous を使って維持の条件を明示できる。維持するかどうかを暗黙にせず、条件として書けるところが要点になる。

Comment
@Reviewer: 依存元の変更で選択をリセットしつつ、利用者の手動選択も残したい要件であれば、`computed` でも `effect` でもなく `linkedSignal` が合います。

computedが例外を投げたときの扱い

computed に書き換えるときに知っておきたい挙動がある。計算中に発生した例外も、計算結果と同じようにメモ化される

依存する Signal が更新されて再評価されるまで、何度読んでも同じ例外が投げ直される。読むたびに計算し直されるわけではない。

例外を投げうる computed
readonly taxIncluded = computed(() => {
  const price = this.price();
  if (price < 0) {
    throw new Error('price must not be negative');
  }
  return Math.floor(price * 1.1);
});

price に負の値が入ると、taxIncluded() を読むたびに例外が投げられる。テンプレートから読んでいれば描画のたびに失敗する。

RxJS のエラーとは復旧の仕方が違う点も押さえておきたい。Observable は error でストリームが終了し、同じ購読では回復しない。computed は依存が更新されれば再評価されるため、price に正しい値を入れれば元に戻る。

この違いがあるため、computed の中で例外を投げる設計は、値で表現できないかを先に検討したい。

Comment
@Reviewer: この `computed` は条件によって例外を投げます。依存が更新されるまで例外がメモ化されるため、テンプレートから読むと描画が失敗し続けます。`null` やエラー用の値を返す形にできませんか。

変更が伝わらない場合の比較

computed へ移したのに再計算されない、という相談の多くは比較の既定値で説明がつく。

Signal の equal の既定は Object.is で、RxJS の distinctUntilChanged() の既定は === である。どちらもオブジェクトの中身までは見ない。

変更が伝わらない更新
addItem(item: Item): void {
  this.items().push(item);
  this.items.set(this.items());
@Reviewer
配列を直接変更したうえで同じ参照を `set` しています。既定の比較では変更と判定されないため、依存する `computed` は再計算されません。
}

配列やオブジェクトを更新するときは、新しい参照を作る。

新しい参照を作る更新
addItem(item: Item): void {
  this.items.update(items => [...items, item]);
}

afterRenderEffect という別の選択肢

DOM を直接読み書きする必要がある処理には、effect() ではなく afterRenderEffect() がある。Angular が DOM を更新した後に実行され、追跡している Signal が変われば再実行される。一度だけの初期化なら afterNextRender() を使う。いずれもサーバーでは実行されない。

チャートの描画、Canvas の操作、要素のサイズ計測がこれにあたる。phase の設計や後始末の書き方は扱う範囲が広いため、この記事では触れるに留める。

状態の派生には computed、Signal の外側へ変更を届けるには effect、DOM の読み書きが必要なときだけ afterRenderEffect という順で検討すれば、選択はおおむね決まる。

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

effectを見たときの確認項目
  • その effect が他の signal を set するだけになっていないか
  • computed で書けない理由(手動更新の要件)があるか。あれば linkedSignal を検討したか
  • computed の中で例外を投げていないか。投げるなら読み手側で扱えるか
  • 配列やオブジェクトの更新で、新しい参照を作っているか
  • DOM の読み書きを effect の中でやっていないか
  • effect の中で始めたタイマーや購読の後始末が登録されているか

おわりに

effect は動く。だから書き換えの判断は、動くかどうかではなく、依存関係がコードから読めるかどうかで決めることになる。

レビューでは effect の本体を見て、そこに set しかないなら computed を提案できる。set に加えて手動更新の要件があるなら linkedSignal になる。Signal の外側へ出ていく処理が入っているなら、それは effect が担うべき処理である。