Angularのeffectを状態同期に使うコードをレビューで止める
Angularのeffectを状態同期に使うコードをレビューで止める
Signal を使い始めた実装でよく見るのが、effect() の中で別の signal を set している形である。依存が変わったら自動で走るので、状態の同期手段として使いたくなる。
Angular の公式ドキュメントも effect() を副作用のためのAPIと位置づけている。ここで言う副作用とは、ログ出力、ストレージへの保存、リアクティブでないライブラリへの反映といった、Signal の外側へ変更を届ける処理を指す。状態から状態を作る処理は含まない。
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 なら「visibleProducts は allProducts と keyword から決まる」という関係が式そのものに現れる。effect の場合、関係を知るには本体を読んで set の対象を確認しなければならない。同期先が複数あればなおさら追いにくくなる。
もう1つは初期状態の扱いである。effect で同期する形では、visibleProducts に初期値を別途与える必要がある。effect が最初に走るまでの間、allProducts の中身と visibleProducts は一致しない。computed にはこの空白がない。
export class ProductListComponent {
readonly allProducts = signal<Product[]>([]);
readonly keyword = signal('');
readonly visibleProducts = computed(() =>
this.allProducts().filter(p => p.name.includes(this.keyword())),
);
}@Reviewer: この `effect` は他の signal を `set` するだけの処理です。`computed` にすると依存が式に現れ、初期値を別に用意する必要もなくなります。派生と手動更新を両立させたい場合
computed へ寄せられない要件もある。派生値でありながら、利用者が手で上書きできる状態がそれにあたる。
一覧の先頭を既定の選択とし、利用者が別の行を選んだらそれを保持したい、という要件を考える。computed は読み取り専用なので set できない。かといって effect で同期すると、利用者の選択を上書きしてしまう。
このために linkedSignal() がある。
readonly selectedProduct = linkedSignal(() => this.visibleProducts()[0] ?? null);
onSelect(product: Product): void {
this.selectedProduct.set(product);
}linkedSignal は、依存元が変わったときに計算し直され、それ以外のときは set された値を保持する writable な signal である。
依存元が変わっても前の選択を維持したい場合は、source と computation を分けて書く形にすると、previous を使って維持の条件を明示できる。維持するかどうかを暗黙にせず、条件として書けるところが要点になる。
@Reviewer: 依存元の変更で選択をリセットしつつ、利用者の手動選択も残したい要件であれば、`computed` でも `effect` でもなく `linkedSignal` が合います。computedが例外を投げたときの扱い
computed に書き換えるときに知っておきたい挙動がある。計算中に発生した例外も、計算結果と同じようにメモ化される。
依存する Signal が更新されて再評価されるまで、何度読んでも同じ例外が投げ直される。読むたびに計算し直されるわけではない。
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 の中で例外を投げる設計は、値で表現できないかを先に検討したい。
@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が他の signal をsetするだけになっていないか computedで書けない理由(手動更新の要件)があるか。あればlinkedSignalを検討したかcomputedの中で例外を投げていないか。投げるなら読み手側で扱えるか- 配列やオブジェクトの更新で、新しい参照を作っているか
- DOM の読み書きを
effectの中でやっていないか effectの中で始めたタイマーや購読の後始末が登録されているか
おわりに
effect は動く。だから書き換えの判断は、動くかどうかではなく、依存関係がコードから読めるかどうかで決めることになる。
レビューでは effect の本体を見て、そこに set しかないなら computed を提案できる。set に加えて手動更新の要件があるなら linkedSignal になる。Signal の外側へ出ていく処理が入っているなら、それは effect が担うべき処理である。