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` にすると依存が式に現れ、初期値を別に用意する必要もなくなります。allowSignalWrites が残っている場合
effect の中で signal を書き換える実装には、かつて allowSignalWrites: true という指定が要った。指定しなければ開発モードで警告が出たため、この形は書いた時点で目に入る状態にあった。
その指定は Angular 22 の型定義で非推奨になっている。
@deprecated no longer required, signal writes are allowed by default.
書き込みが既定で許可されたので、effect の中の set はもう何も報告されない。この記事で挙げた形は、フレームワークの側から見れば正常な使い方のひとつになった。computed へ寄せる判断は、警告の有無ではなく依存関係が式に現れているかどうかで決めることになる。
既存のコードに allowSignalWrites: true が残っているなら、それは effect の中に set があることを書いた本人が意識していた痕跡である。非推奨の指定を外す作業のついでに、その effect が computed か linkedSignal で書けないかを確かめておきたい。
@Reviewer: `allowSignalWrites` は 22 で非推奨になっています。外して構いませんが、この `effect` が `visibleProducts` を `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` が合います。書き込みを受け取る set オプション
Angular 22.1 で linkedSignal に set オプションが入った。(value, rawSet) => void という形で、set() と update() の呼び出しをいったん受け取れる。
readonly selectedProduct = linkedSignal<Product | null>({
source: () => this.visibleProducts(),
computation: (products) => products[0] ?? null,
set: (product, rawSet) => {
if (product === null || product.selectable) {
rawSet(product);
}
},
});実際の書き込みは rawSet を呼んだときだけ起きる。条件に合わない呼び出しをそのまま捨てられるため、入力の検証をコンポーネント側に散らさずに済む。
裏返すと、rawSet を呼ばない経路を書けば、set() が成功したように見えて値が変わらない状態になる。戻り値も例外も無いので、呼び出し側からは区別できない。
update() も同じ関数を通る。渡した更新関数は現在値に対して untracked で評価され、その結果が set オプションの第1引数として届く。つまり update を使っても検証を迂回できない。
@Reviewer: `set` オプションの中で `rawSet` を呼ばない分岐があります。この経路では `set()` が無言で捨てられるため、呼び出し側が結果を確認できません。弾いたことを別の signal に残すか、呼び出し前に検証してください。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を検討したか- 非推奨になった
allowSignalWritesが残っていないか。残っているなら、そのeffectの中身を確かめたか linkedSignalのsetオプションに、rawSetを呼ばずに終わる経路が無いかcomputedの中で例外を投げていないか。投げるなら読み手側で扱えるか- 配列やオブジェクトの更新で、新しい参照を作っているか
- DOM の読み書きを
effectの中でやっていないか effectの中で始めたタイマーや購読の後始末が登録されているか
おわりに
effect は動く。だから書き換えの判断は、動くかどうかではなく、依存関係がコードから読めるかどうかで決めることになる。
レビューでは effect の本体を見て、そこに set しかないなら computed を提案できる。set に加えて手動更新の要件があるなら linkedSignal になる。Signal の外側へ出ていく処理が入っているなら、それは effect が担うべき処理である。