Angularの購読と副作用の寿命を注入コンテキストから読む

takeUntilDestroyed() が書いてあれば購読は漏れない、という読み方をすると見落とすものがある。このOperatorが購読を終わらせるのは、渡した DestroyRef のスコープが破棄された時であって、それを呼び出したコードの寿命ではない。

Angular 22 の実装を見ると、引数を省略した場合は注入コンテキストから DestroyRef を取得している。つまり「どこで呼んだか」が寿命を決める。

rootに提供したServiceの寿命

次のコードは、コンポーネントから呼ばれることを想定した Service の中にある。

rootの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() を見たときは、それがどのコンテキストで解決されるかを確認したい。

Comment
@Reviewer: この `takeUntilDestroyed` は root の Service の中で呼ばれています。破棄されるのはアプリ終了時なので、画面を離れても購読が残ります。呼び出し元の `DestroyRef` を渡す形にできますか。

switchMap との前後関係

takeUntilDestroyedtakeUntil で実装されている。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 の後ろに置く。

内部Observableまで解除する配置
source$
  .pipe(
    switchMap(value => this.api.load(value)),
    takeUntilDestroyed(destroyRef),
  )
  .subscribe();
Comment
@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 を先に取得して渡す。

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 の中で、呼び出し元の寿命を期待していないか
  • takeUntilDestroyedswitchMap の前後どちらにあるか。内部Observableまで解除する意図か
  • toSignal / effect / resource 系を ngOnInit やハンドラの中で作っていないか
  • Injector を渡す形にした箇所で、その Injector の寿命が意図と合っているか
  • SignalStore の provider の位置が、状態の寿命として意図的に選ばれているか

おわりに

購読と副作用の寿命は、書いたOperatorの名前ではなく、そのコードが属するスコープで決まる。takeUntilDestroyed はスコープを購読に結びつける道具であって、スコープそのものを選んでくれるわけではない。

レビューで確認するのは2点に絞れる。どの DestroyRef に紐づいているか、そして内部Observableまで含めて解除したいのかどうか。この2つが答えられていれば、購読が残る不具合はほとんど手前で止まる。