Angularの購読と副作用の寿命を注入コンテキストから読む
Angularの購読と副作用の寿命を注入コンテキストから読む
takeUntilDestroyed() が書いてあれば購読は漏れない、という読み方をすると見落とすものがある。このOperatorが購読を終わらせるのは、渡した DestroyRef のスコープが破棄された時であって、それを呼び出したコードの寿命ではない。
Angular 22 の実装を見ると、引数を省略した場合は注入コンテキストから DestroyRef を取得している。つまり「どこで呼んだか」が寿命を決める。
rootに提供したServiceの寿命
次のコードは、コンポーネントから呼ばれることを想定した Service の中にある。
@Injectable({ providedIn: 'root' })
export class NoticePollingService {
private readonly api = inject(NoticeApi);
start(): void {
interval(30_000)
.pipe(
switchMap(() => this.api.fetchNotices()),
takeUntilDestroyed(),
)
.subscribe(notices => this.notices.set(notices));
@Reviewerこの `takeUntilDestroyed()` は root に提供された Service の注入コンテキストで解決されます。破棄されるのはアプリ終了時なので、呼び出し元の画面を離れてもポーリングは続きます。 }
}providedIn: 'root' の Service は、アプリの生存期間にわたって存在する。その注入コンテキストで取得した DestroyRef が破棄されるのもアプリ終了時になる。画面を行き来するたびに start() が呼ばれれば、購読はそのぶん増える。
呼び出し元の画面に寿命を合わせたいなら、DestroyRef を呼び出し側から渡す。
// Service 側
start(destroyRef: DestroyRef): void {
interval(30_000)
.pipe(
switchMap(() => this.api.fetchNotices()),
takeUntilDestroyed(destroyRef),
)
.subscribe(notices => this.notices.set(notices));
}
// コンポーネント側
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.polling.start(this.destroyRef);
}引数を省略できるのは注入コンテキストの中だけなので、この形なら「誰の寿命に合わせるか」がコードに現れる。省略されている takeUntilDestroyed() を見たときは、それがどのコンテキストで解決されるかを確認したい。
@Reviewer: この `takeUntilDestroyed` は root の Service の中で呼ばれています。破棄されるのはアプリ終了時なので、画面を離れても購読が残ります。呼び出し元の `DestroyRef` を渡す形にできますか。switchMap との前後関係
takeUntilDestroyed は takeUntil で実装されている。Angular 22.1.6 のソースでも、返しているのは source => source.pipe(takeUntil(destroyed$)) である。
この事実から、配置の意味が決まる。takeUntil が働くのは、それより上流のストリームに対してであり、下流で始まった内部Observableには届かない。
source$
.pipe(
takeUntilDestroyed(destroyRef),
switchMap(value => this.api.load(value)),
)
.subscribe();破棄が起きた時点で source$ からの通知は止まる。しかし既に始まっている this.api.load(value) は、switchMap が内部Observableの complete を待つため、そのまま最後まで走る。
実際に流すと違いがはっきりする。破棄を発火させてから内部処理の完了を待つと、次のようになる。
[前に置いた場合] 破棄 → next inner-done → complete
[後ろに置いた場合] 破棄 → complete(内部の値は出ない)破棄後に内部処理の結果を受け取りたくないなら、switchMap の後ろに置く。
source$
.pipe(
switchMap(value => this.api.load(value)),
takeUntilDestroyed(destroyRef),
)
.subscribe();@Reviewer: `takeUntilDestroyed` が `switchMap` の前にあります。破棄しても内部の通信は最後まで走り、その結果で `set` が呼ばれます。意図した順序か確認させてください。注入コンテキストの外で作れないもの
寿命の話は購読だけに留まらない。toSignal / toObservable / effect / resource 系は、いずれも作成時の注入コンテキストに寿命が紐づく。そのため、注入コンテキストの外では作れない。
注入コンテキストになるのは、フィールドの初期化子とコンストラクタの中である。ngOnInit やクリックハンドラは注入コンテキストではない。
export class UserComponent implements OnInit {
users!: Signal<User[]>;
ngOnInit(): void {
this.users = toSignal(this.api.fetchUsers(), { initialValue: [] });
@Reviewer`ngOnInit` は注入コンテキストではありません。フィールドの初期化子へ移すか、事前に取得した `Injector` を渡してください。 }
}どうしても後から作る必要があるなら、Injector を先に取得して渡す。
private readonly injector = inject(Injector);
onOpen(): void {
this.detail = toSignal(this.api.fetchDetail(this.id()), {
initialValue: null,
injector: this.injector,
});
}ただし、後から作る形が必要になる場面は多くない。フィールドの初期化子で作れるなら、そのほうが寿命の所在も読み取りやすい。
rxMethod の購読はどこに紐づくか
SignalStore の rxMethod に Signal や Observable を接続した場合、その購読は SignalStore が作られたときの Injector の破棄に合わせて片付く。呼び出したコンポーネントの寿命ではない。
したがって、寿命を決めているのは store をどこに provide したかになる。コンポーネントの providers に置けばその画面と一緒に破棄され、root に置けばアプリの生存期間にわたって残る。
ここでも判断の軸は同じで、「誰のスコープに紐づいているか」を問えば答えが出る。
レビュー観点チェックリスト
- 引数を省略した
takeUntilDestroyed()が、どの注入コンテキストで解決されるか - root に提供された Service の中で、呼び出し元の寿命を期待していないか
takeUntilDestroyedがswitchMapの前後どちらにあるか。内部Observableまで解除する意図かtoSignal/effect/resource系をngOnInitやハンドラの中で作っていないかInjectorを渡す形にした箇所で、そのInjectorの寿命が意図と合っているか- SignalStore の provider の位置が、状態の寿命として意図的に選ばれているか
おわりに
購読と副作用の寿命は、書いたOperatorの名前ではなく、そのコードが属するスコープで決まる。takeUntilDestroyed はスコープを購読に結びつける道具であって、スコープそのものを選んでくれるわけではない。
レビューで確認するのは2点に絞れる。どの DestroyRef に紐づいているか、そして内部Observableまで含めて解除したいのかどうか。この2つが答えられていれば、購読が残る不具合はほとんど手前で止まる。