Angular の unsubscribe(購読解除)とは、subscribe() で始めた Observable の受け取りを、コンポーネントが破棄されるときなどに止めることです。止めないままにすると、画面を閉じたあともコールバックが動き続け、メモリリークや二重実行のもとになります。 今の Angular では、takeUntilDestroyed()・async パイプ・toSignal() を使うと、解除のコードを自分で書かずに済みます。

この記事では、Angular 22.2.1 で 5つの解除方法を同じ題材(100ms ごとに値を流す interval)で書き比べ、コンポーネントを破棄したあとにログが止まるかどうかを確かめました。さらに、HttpClient・ActivatedRoute・interval・FormControl.valueChanges・Subject のそれぞれで「解除しないと何が起きるか」を動かし、要否の早見表にまとめています。インジェクションコンテキストの外で呼んだときの NG0203 のエラー文と直し方も載せました。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular 22.2.1(@angular/core・@angular/cli)/ RxJS 7.8.2 / TypeScript 6.0.3 / Vitest 5.0.3(jsdom 30.1.1)。ng new で作った最小構成のプロジェクトで、ng test --watch=false --reporters=verbose の出力を載せています。

Angular unsubscribe の早見表

5つの解除方法

方法 書く場所 解除のコード 向いている場面
1. Subscription を持って ngOnDestroy で unsubscribe() どこでも 自分で書く 途中で止めたり、再開したりしたいとき
2. takeUntil(destroy$) と Subject どこでも ngOnDestroy で next() を書く 従来からのコードに合わせるとき
3. takeUntilDestroyed() コンストラクター・フィールド初期化(それ以外は DestroyRef を渡す) 不要 subscribe の中で処理をしたいとき(基本はこれ)
4. async パイプ テンプレート 不要 値を画面に出すだけのとき
5. toSignal() コンストラクター・フィールド初期化(それ以外は injector を渡す) 不要 値を signal として使いたいとき

購読先ごとの要否

「解除しない場合」の列は、後半のコードを動かして確かめた結果です。

購読先 解除しない場合に起きたこと 要否
interval 破棄後も値が流れ、コールバックが動き続けた 必要
サービスが持つ Subject(アプリ全体で共有) 画面を開いた回数だけ購読が残り、1回の通知で過去の分まで動いた 必要
HttpClient 応答が届くと自動で完了(closed = true)。ただし応答前に破棄すると、破棄後にコールバックが動いた 付けておくと安心(付けるとリクエストも取り消される)
ActivatedRoute(paramMap など) 破棄後は値が流れてこなかった(closed は false のまま) 書かなくても動く。そろえるなら takeUntilDestroyed()
コンポーネント自身が持つ FormControl.valueChanges 破棄後も closed は false。値を入れればコールバックは動く フォームがコンポーネントと一緒に役目を終えるなら書かなくても動く。サービスが持つフォームなら必要
async パイプ・toSignal() で受けたもの 破棄と同時に購読が終了した 不要(自動)

迷ったら「subscribe() を書いたら takeUntilDestroyed() を付ける。画面に出すだけなら async パイプか toSignal()」 と決めておくと、購読先ごとに考えずに済みます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular unsubscribeの方法5つと要否早見表」

なぜ解除が必要か:破棄後も動き続ける様子を再現する

Observable は、subscribe() した側が「もう要らない」と伝えるか、Observable 自身が完了するまで値を流し続けます。コンポーネントが画面から消えても、購読は自動では止まりません。

5つの方法と「解除なし」を同じ題材で書く

題材は、100ms ごとに 0, 1, 2… を流す interval です。値が流れるたびと、購読が終わるときにログを出します。src/app/unsub1.spec.ts として保存すると、そのまま実行できます。

import { AsyncPipe } from '@angular/common';
import { Component, OnDestroy, OnInit, Type } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { TestBed } from '@angular/core/testing';
import { Subject, Subscription, finalize, interval, takeUntil, tap } from 'rxjs';
import { afterEach, beforeEach, it, vi } from 'vitest';

// 共通の題材:100ms ごとに 0, 1, 2… を流す。値が流れるたび・購読が終わるときにログを出す
const ticker = (name: string) =>
  interval(100).pipe(
    tap((n) => console.log(`  [${name}] tick ${n}`)),
    finalize(() => console.log(`  [${name}] 購読が終了`)),
  );

// 0. 解除しない
@Component({ selector: 'app-leak', template: `{{ count }}` })
class LeakTicker implements OnInit {
  count = 0;
  ngOnInit() {
    ticker('解除なし').subscribe((n) => (this.count = n));
  }
}

// 1. Subscription を持っておき、ngOnDestroy で unsubscribe
@Component({ selector: 'app-t1', template: `{{ count }}` })
class Ticker1 implements OnInit, OnDestroy {
  count = 0;
  private sub?: Subscription;
  ngOnInit() {
    this.sub = ticker('1.Subscription').subscribe((n) => (this.count = n));
  }
  ngOnDestroy() {
    this.sub?.unsubscribe();
  }
}

// 2. takeUntil と Subject
@Component({ selector: 'app-t2', template: `{{ count }}` })
class Ticker2 implements OnInit, OnDestroy {
  count = 0;
  private destroy$ = new Subject<void>();
  ngOnInit() {
    ticker('2.takeUntil').pipe(takeUntil(this.destroy$)).subscribe((n) => (this.count = n));
  }
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

// 3. takeUntilDestroyed(コンストラクターやフィールド初期化で呼ぶ)
@Component({ selector: 'app-t3', template: `{{ count }}` })
class Ticker3 {
  count = 0;
  constructor() {
    ticker('3.takeUntilDestroyed').pipe(takeUntilDestroyed()).subscribe((n) => (this.count = n));
  }
}

// 4. async パイプ(subscribe を書かない)
@Component({ selector: 'app-t4', imports: [AsyncPipe], template: `{{ count$ | async }}` })
class Ticker4 {
  count$ = ticker('4.asyncパイプ');
}

// 5. toSignal(subscribe を書かない)
@Component({ selector: 'app-t5', template: `{{ count() }}` })
class Ticker5 {
  count = toSignal(ticker('5.toSignal'), { initialValue: 0 });
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

async function run(type: Type<unknown>) {
  const fixture = TestBed.createComponent(type);
  fixture.detectChanges();
  await vi.advanceTimersByTimeAsync(250);
  console.log('  --- コンポーネントを破棄 ---');
  fixture.destroy();
  await vi.advanceTimersByTimeAsync(250);
  console.log('  (破棄から 250ms 経過)');
}

it('0. 解除なし', () => run(LeakTicker));
it('1. Subscription', () => run(Ticker1));
it('2. takeUntil', () => run(Ticker2));
it('3. takeUntilDestroyed', () => run(Ticker3));
it('4. async パイプ', () => run(Ticker4));
it('5. toSignal', () => run(Ticker5));

実行結果:解除なしは破棄後も tick が出続ける

0. 解除なし
  [解除なし] tick 0
  [解除なし] tick 1
  --- コンポーネントを破棄 ---
  [解除なし] tick 2
  [解除なし] tick 3
  [解除なし] tick 4
  (破棄から 250ms 経過)
1. Subscription
  [1.Subscription] tick 0
  [1.Subscription] tick 1
  --- コンポーネントを破棄 ---
  [1.Subscription] 購読が終了
  (破棄から 250ms 経過)
2. takeUntil
  [2.takeUntil] tick 0
  [2.takeUntil] tick 1
  --- コンポーネントを破棄 ---
  [2.takeUntil] 購読が終了
  (破棄から 250ms 経過)
3. takeUntilDestroyed
  [3.takeUntilDestroyed] tick 0
  [3.takeUntilDestroyed] tick 1
  --- コンポーネントを破棄 ---
  [3.takeUntilDestroyed] 購読が終了
  (破棄から 250ms 経過)
4. async パイプ
  [4.asyncパイプ] tick 0
  [4.asyncパイプ] tick 1
  --- コンポーネントを破棄 ---
  [4.asyncパイプ] 購読が終了
  (破棄から 250ms 経過)
5. toSignal
  [5.toSignal] tick 0
  [5.toSignal] tick 1
  --- コンポーネントを破棄 ---
  [5.toSignal] 購読が終了
  (破棄から 250ms 経過)
  • 「0. 解除なし」は、コンポーネントを破棄したあとも tick 2・tick 3・tick 4 が出続けています。「購読が終了」のログも出ていません。コールバックは破棄済みのコンポーネントの count に代入を続けていて、コンポーネントも購読から参照されたままになります。これがメモリリークと呼ばれる状態です。
  • 1〜5 は、破棄と同時に「購読が終了」が出て、そのあとは tick が出ていません。 5つとも同じ結果で、違いは書き方だけです。

5つの方法の書き方と選び方

方法1:Subscription を持って ngOnDestroy で unsubscribe

subscribe() の戻り値(Subscription)を取っておき、ngOnDestroy で unsubscribe() を呼びます。いちばん素直な書き方で、ボタンで一時停止するなど、破棄より前に自分のタイミングで止めたいときに向いています。購読が増えるとプロパティも増えるので、数が多いときはほかの方法が楽です。

方法2:takeUntil と Subject

「この Subject に値が流れたら完了する」という takeUntil を pipe に入れ、ngOnDestroy で Subject に値を流します。購読がいくつあっても Subject は1つで済みます。takeUntilDestroyed() が登場する前から広く使われてきた書き方で、仕組みは方法3と同じです。

方法3:takeUntilDestroyed(基本はこれ)

@angular/core/rxjs-interop の takeUntilDestroyed() を pipe に入れるだけです。公式 API リファレンスでは「呼び出したコンテキスト(コンポーネント・ディレクティブ・サービスなど)が破棄されたときに Observable を完了させる」と説明されています。ngOnDestroy も Subject も要りません。

呼べる場所に決まりがあり、引数なしで呼べるのはコンストラクターとフィールド初期化の中です(理由と直し方は NG0203 の章で扱います)。

方法4:async パイプ

テンプレートで {{ count$ | async }} と書くと、async パイプが購読し、コンポーネントの破棄に合わせて解除します。クラス側に subscribe が1つも出てこないのが利点です。値を画面に出すだけなら、これがいちばん短く書けます。

方法5:toSignal

toSignal(obs$) は Observable を signal に変えます。公式ドキュメント(RxJS interop)には、toSignal を呼んだコンポーネントやサービスが破棄されると自動で購読を解除する、と書かれています。テンプレートでは {{ count() }} と読み、computed() などほかの signal と組み合わせられます。

選び方の表

やりたいこと 選ぶ方法
値をテンプレートに出すだけ async パイプ、または toSignal()
値を使って別の計算をしたい(computed など) toSignal()
subscribe の中で処理をしたい(ログ、画面遷移、保存など) takeUntilDestroyed()
ngOnInit やメソッドの中で購読を始めたい takeUntilDestroyed(this.destroyRef)
破棄より前に、自分のタイミングで止めたい Subscription を持って unsubscribe()
従来の takeUntil(destroy$) のコードがすでにある そのままで動く。新しく書く部分から takeUntilDestroyed() に

購読先ごとに解除の要否を確かめる

ここからは、早見表の「購読先ごとの要否」を1つずつ動かします。コードはすべて1つのファイル(src/app/unsub2.spec.ts)に続けて書いたもので、import は次のとおりです。

import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component, Injectable, OnInit, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TestBed } from '@angular/core/testing';
import { FormControl } from '@angular/forms';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { Subject, Subscription } from 'rxjs';
import { it } from 'vitest';

HttpClient:自動で完了するが、破棄後にコールバックが動くことがある

@Component({ selector: 'app-http-plain', template: `{{ name }}` })
class HttpPlain implements OnInit {
  private http = inject(HttpClient);
  name = '';
  sub?: Subscription;
  ngOnInit() {
    this.sub = this.http.get<{ name: string }>('/api/me').subscribe((v) => {
      console.log('  コールバック実行: name =', v.name);
      this.name = v.name;
    });
  }
}

@Component({ selector: 'app-http-safe', template: `{{ name }}` })
class HttpSafe {
  name = '';
  constructor() {
    inject(HttpClient).get<{ name: string }>('/api/me').pipe(takeUntilDestroyed()).subscribe((v) => {
      console.log('  コールバック実行: name =', v.name);
      this.name = v.name;
    });
  }
}

it('HttpClient', () => {
  TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
  const http = TestBed.inject(HttpTestingController);

  console.log('A. 応答を受け取ったあと');
  const a = TestBed.createComponent(HttpPlain);
  a.detectChanges();
  console.log('  応答前: closed =', a.componentInstance.sub!.closed);
  http.expectOne('/api/me').flush({ name: 'taro' });
  console.log('  応答後: closed =', a.componentInstance.sub!.closed);

  console.log('B. 応答の前にコンポーネントを破棄(解除なし)');
  const b = TestBed.createComponent(HttpPlain);
  b.detectChanges();
  b.destroy();
  const reqB = http.expectOne('/api/me');
  console.log('  破棄後: リクエストは取り消されたか =', reqB.cancelled);
  reqB.flush({ name: 'jiro' });

  console.log('C. 応答の前にコンポーネントを破棄(takeUntilDestroyed)');
  const c = TestBed.createComponent(HttpSafe);
  c.detectChanges();
  const reqC = http.expectOne('/api/me');
  c.destroy();
  console.log('  破棄後: リクエストは取り消されたか =', reqC.cancelled);
});
A. 応答を受け取ったあと
  応答前: closed = false
  コールバック実行: name = taro
  応答後: closed = true
B. 応答の前にコンポーネントを破棄(解除なし)
  破棄後: リクエストは取り消されたか = false
  コールバック実行: name = jiro
C. 応答の前にコンポーネントを破棄(takeUntilDestroyed)
  破棄後: リクエストは取り消されたか = true
  • A:応答が届くと closed = true になります。HttpClient の Observable は応答のあと自動で完了するので、購読が残り続けることはありません。
  • B:応答の前にコンポーネントを破棄しても、リクエストは取り消されず、応答が届いた時点でコールバックが動きました。コールバックの中で画面遷移や通知の表示をしていると、もう開いていない画面の処理が動くことになります。
  • C:takeUntilDestroyed() を付けると、破棄と同時にリクエストが取り消されました(cancelled = true)。

公式ドキュメント(Making HTTP requests)にも、購読を解除すると進行中のリクエストが中断されること、コンポーネントの破棄時に購読を片付けておかないと、破棄済みのコンポーネントに対してコールバックが動く場合があることが書かれており、async パイプや toSignal の利用が勧められています。

ActivatedRoute:破棄後は値が流れてこない

const routeSubs: Subscription[] = [];
@Component({ selector: 'app-item-page', template: `item` })
class ItemPage implements OnInit {
  private route = inject(ActivatedRoute);
  private no = routeSubs.length + 1; // 何番目に作られた ItemPage か
  ngOnInit() {
    routeSubs.push(this.route.paramMap.subscribe({
      next: (p) => console.log(`  ItemPage#${this.no} next: id = ${p.get('id')}`),
      complete: () => console.log(`  ItemPage#${this.no} complete が呼ばれた`),
    }));
  }
}
@Component({ selector: 'app-other-page', template: `other` })
class OtherPage {}

it('ActivatedRoute', async () => {
  TestBed.configureTestingModule({
    providers: [provideRouter([{ path: 'items/:id', component: ItemPage }, { path: 'other', component: OtherPage }])],
  });
  const harness = await RouterTestingHarness.create();
  console.log('/items/1 へ移動');
  await harness.navigateByUrl('/items/1');
  console.log('/items/2 へ移動(同じコンポーネントのまま)');
  await harness.navigateByUrl('/items/2');
  console.log('/other へ移動(ItemPage#1 は破棄される)');
  await harness.navigateByUrl('/other');
  console.log('  ItemPage#1 の購読: closed =', routeSubs[0].closed);
  console.log('/items/3 へ移動(新しい ItemPage#2 が作られる)');
  await harness.navigateByUrl('/items/3');
});
/items/1 へ移動
  ItemPage#1 next: id = 1
/items/2 へ移動(同じコンポーネントのまま)
  ItemPage#1 next: id = 2
/other へ移動(ItemPage#1 は破棄される)
  ItemPage#1 の購読: closed = false
/items/3 へ移動(新しい ItemPage#2 が作られる)
  ItemPage#2 next: id = 3

/other へ移動して ItemPage#1 が破棄されたあと、/items/3 へ移動すると、反応したのは新しく作られた ItemPage#2 だけでした。破棄されたコンポーネント(#1)の購読には、値が流れてきていません。

一方で、complete は呼ばれず closed も false のままでした。「書かなくても動くが、完了の通知は来ない」という結果です。ほかの購読と書き方をそろえるなら、takeUntilDestroyed() を付けておくと迷いません。

FormControl.valueChanges:フォームの持ち主で決まる

@Component({ selector: 'app-form-owner', template: `` })
class FormOwner implements OnInit {
  keyword = new FormControl('', { nonNullable: true });
  sub?: Subscription;
  ngOnInit() {
    this.sub = this.keyword.valueChanges.subscribe((v) => console.log('  コールバック実行:', v));
  }
}

it('FormControl.valueChanges', () => {
  const f = TestBed.createComponent(FormOwner);
  f.detectChanges();
  const c = f.componentInstance;
  c.keyword.setValue('破棄前の入力');
  f.destroy();
  console.log('  破棄後: closed =', c.sub!.closed);
  c.keyword.setValue('破棄後に setValue');
});
  コールバック実行: 破棄前の入力
  破棄後: closed = false
  コールバック実行: 破棄後に setValue

破棄後も closed は false で、FormControl に値を入れるとコールバックが動きました。valueChanges は自分からは完了しません。

コンポーネント自身が持つ FormControl なら、破棄後にそのフォームへ値を入れる人がいなくなるので、コールバックが動く機会はなくなります。フォームをサービスに置いて複数の画面で共有している場合は、次の「サービスの Subject」と同じ形になるので解除が必要です。

サービスの Subject:開いた回数だけ購読が積み重なる

@Injectable({ providedIn: 'root' })
class NoticeService {
  notice$ = new Subject<string>();
}

let nextId = 1;
@Component({ selector: 'app-notice', template: `` })
class NoticeView implements OnInit {
  private service = inject(NoticeService);
  private id = nextId++;
  ngOnInit() {
    this.service.notice$.subscribe((msg) => console.log(`  コンポーネント#${this.id} が受信: ${msg}`));
  }
}

let nextSafeId = 1;
@Component({ selector: 'app-notice-safe', template: `` })
class NoticeViewSafe {
  private id = nextSafeId++;
  constructor() {
    inject(NoticeService).notice$.pipe(takeUntilDestroyed())
      .subscribe((msg) => console.log(`  コンポーネント#${this.id} が受信: ${msg}`));
  }
}

it('サービスの Subject', () => {
  const service = TestBed.inject(NoticeService);
  console.log('解除なし: 画面を3回開いて閉じたあと、4回目を開いて通知を1回流す');
  for (let i = 0; i < 3; i++) {
    const f = TestBed.createComponent(NoticeView);
    f.detectChanges();
    f.destroy();
  }
  const last = TestBed.createComponent(NoticeView);
  last.detectChanges();
  service.notice$.next('新着があります');
  last.destroy();

  service.notice$ = new Subject<string>();
  console.log('takeUntilDestroyed: 同じ操作');
  for (let i = 0; i < 3; i++) {
    const f = TestBed.createComponent(NoticeViewSafe);
    f.detectChanges();
    f.destroy();
  }
  const lastSafe = TestBed.createComponent(NoticeViewSafe);
  lastSafe.detectChanges();
  service.notice$.next('新着があります');
});
解除なし: 画面を3回開いて閉じたあと、4回目を開いて通知を1回流す
  コンポーネント#1 が受信: 新着があります
  コンポーネント#2 が受信: 新着があります
  コンポーネント#3 が受信: 新着があります
  コンポーネント#4 が受信: 新着があります
takeUntilDestroyed: 同じ操作
  コンポーネント#4 が受信: 新着があります

解除なしでは、画面を4回開いただけで、1回の通知に対して4つのコールバックが動きました。 #1〜#3 はすでに破棄されたコンポーネントです。providedIn: 'root' のサービスはアプリが動いている間ずっと生きているので、そこにある Subject への購読は、解除しない限り残り続けます。「同じ処理が何回も動く」「画面を行き来するほど重くなる」という現象は、この形で起きます。

takeUntilDestroyed() を付けたほうは、いま開いている #4 だけが受信しています。

要否の判断の流れ

順番 確かめること 答えが「はい」なら
1 画面に出すだけか async パイプか toSignal()。解除のコードは不要
2 購読先はコンポーネントより長生きか(root のサービスが持つ Subject、interval など) 解除が必要。takeUntilDestroyed() を付ける
3 1回流れたら完了するか(HttpClient、take(1)、first()) 購読は残らない。破棄後にコールバックが動くと困るなら takeUntilDestroyed()
4 どれか分からない takeUntilDestroyed() を付ける(どの購読先でも破棄と同時に止まる)

NG0203:インジェクションコンテキスト外で呼んだときのエラーと直し方

takeUntilDestroyed() と toSignal() は、内部で inject() を使って「いまのコンポーネントが破棄されるタイミング」を受け取ります。そのため、inject() が使える場所(インジェクションコンテキスト)で呼ぶ必要があります。公式ドキュメントの NG0203 のページでは、使える場所としてフィールド初期化、コンストラクター、プロバイダーのファクトリ関数が挙げられ、ngOnInit() などのライフサイクルフックやメソッドの中は対象外と説明されています。

再現コードと直し方

import { Component, DestroyRef, Injector, OnInit, Signal, Type, inject } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { TestBed } from '@angular/core/testing';
import { finalize, interval } from 'rxjs';
import { it } from 'vitest';

const source = (name: string) => interval(100).pipe(finalize(() => console.log(`  [${name}] 購読が終了`)));

// エラーになる例:ngOnInit で引数なしの takeUntilDestroyed() を呼ぶ
@Component({ selector: 'app-ng1', template: `` })
class InOnInit implements OnInit {
  ngOnInit() {
    source('ngOnInit').pipe(takeUntilDestroyed()).subscribe();
  }
}

// 直し方:DestroyRef をフィールドで inject しておき、引数に渡す
@Component({ selector: 'app-ok1', template: `` })
class WithDestroyRef implements OnInit {
  private destroyRef = inject(DestroyRef);
  ngOnInit() {
    source('DestroyRef を渡す').pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
  }
}

// エラーになる例:ngOnInit で toSignal() を呼ぶ
@Component({ selector: 'app-ng2', template: `` })
class ToSignalInOnInit implements OnInit {
  count?: Signal<number>;
  ngOnInit() {
    this.count = toSignal(source('toSignal'), { initialValue: 0 });
  }
}

// 直し方:Injector を渡す
@Component({ selector: 'app-ok2', template: `` })
class ToSignalWithInjector implements OnInit {
  private injector = inject(Injector);
  count?: Signal<number>;
  ngOnInit() {
    this.count = toSignal(source('Injector を渡す'), { initialValue: 0, injector: this.injector });
  }
}

function run(label: string, type: Type<unknown>) {
  console.log(label);
  const fixture = TestBed.createComponent(type);
  try {
    fixture.detectChanges();
    console.log('  エラーなし');
  } catch (e) {
    console.log('  ' + (e as Error).message);
  }
  fixture.destroy();
}

it('NG0203', () => {
  run('ngOnInit で takeUntilDestroyed()', InOnInit);
  run('ngOnInit で takeUntilDestroyed(this.destroyRef)', WithDestroyRef);
  run('ngOnInit で toSignal(obs$)', ToSignalInOnInit);
  run('ngOnInit で toSignal(obs$, { injector })', ToSignalWithInjector);
});

実行結果(エラー文はそのまま):

ngOnInit で takeUntilDestroyed()
  NG0203: takeUntilDestroyed() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
ngOnInit で takeUntilDestroyed(this.destroyRef)
  エラーなし
  [DestroyRef を渡す] 購読が終了
ngOnInit で toSignal(obs$)
  NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
ngOnInit で toSignal(obs$, { injector })
  エラーなし
  [Injector を渡す] 購読が終了

直し方のまとめ

状況 エラー文の先頭 直し方
ngOnInit やメソッドで takeUntilDestroyed() NG0203: takeUntilDestroyed() can only be used within an injection context ... private destroyRef = inject(DestroyRef); をフィールドに書き、takeUntilDestroyed(this.destroyRef) と渡す
ngOnInit やメソッドで toSignal() NG0203: toSignal() can only be used within an injection context ... private injector = inject(Injector); をフィールドに書き、toSignal(obs$, { injector: this.injector }) と渡す
そもそも ngOnInit である必要がない ― 購読をコンストラクターかフィールド初期化へ移す(引数なしで呼べる)

DestroyRef を渡した場合も、Injector を渡した場合も、コンポーネントの破棄と同時に「購読が終了」のログが出ており、解除は正しく働いています。input の値を使うために ngOnInit で購読を始めたいときは、この「DestroyRef を渡す」形がそのまま使えます。

解除し忘れを見つけるチェックリスト

確かめること 見つけ方 直し方
.subscribe( を書いている箇所に、解除の仕組みがあるか エディターで .subscribe( を検索する takeUntilDestroyed() を pipe に入れる
画面を開き直すたびに、同じログや通信が増えていないか 画面を数回行き来して、コンソールのログの数を見る サービスの Subject への購読に解除を付ける
ngOnInit で takeUntilDestroyed() を引数なしで呼んでいないか 実行時に NG0203 が出る DestroyRef を渡す
subscribe の中で値を代入して画面に出しているだけではないか テンプレートでその値を出している箇所を探す async パイプか toSignal() に置き換える
テストで解除を確かめたい finalize() でログを出し、fixture.destroy() のあとに出るかを見る(今回の方法) ―

よくある質問

Q. HttpClient は unsubscribe しなくてよいのですか? A. 応答が届けば自動で完了するので、購読が残り続けることはありません(実行結果の A)。ただ、応答の前に画面を離れると、破棄後にコールバックが動きます(B)。takeUntilDestroyed() を付けておくと、破棄と同時にリクエストごと取り消されます(C)。

Q. takeUntil と takeUntilDestroyed はどちらを使えばよいですか? A. 新しく書くなら takeUntilDestroyed() です。Subject の用意と ngOnDestroy が要らなくなります。今回の実行結果では、どちらも破棄と同時に購読が終了しており、動きは同じでした。

Q. async パイプと toSignal はどう使い分けますか? A. テンプレートに出すだけならどちらでも同じ結果になります。値をクラスの中でも使いたい、computed() で別の値を作りたい、というときは toSignal() が便利です。

Q. 解除を書き忘れると、エラーで教えてもらえますか? A. エラーは出ません。今回の「解除なし」の例も、テスト自体は成功したうえで、破棄後に tick のログだけが出続けました。気づく手がかりは「同じログや通信が増えていく」ことなので、subscribe() を書いた時点で takeUntilDestroyed() を付ける習慣にしておくと安心です。

まとめ

  • subscribe() は、解除するか Observable が完了するまで動き続ける。解除しない interval は、コンポーネントの破棄後も tick を出し続けた。
  • 解除の方法は5つ:Subscription、takeUntil、takeUntilDestroyed()、async パイプ、toSignal()。どれも破棄と同時に購読が終了した。
  • 基本は「subscribe を書いたら takeUntilDestroyed()、画面に出すだけなら async パイプか toSignal()」。
  • 解除が必要なのは、コンポーネントより長生きする購読先(interval、root のサービスの Subject など)。Subject の例では、開いた回数だけコールバックが増えた。
  • HttpClient は自動で完了するが、応答前に破棄するとコールバックが動く。takeUntilDestroyed() を付けるとリクエストも取り消される。
  • ActivatedRoute は破棄後に値が流れてこなかった。valueChanges はフォームの持ち主がサービスなら解除が必要。
  • NG0203 が出たら、DestroyRef(toSignal は Injector)をフィールドで inject して渡す。

参考・出典

  • Angular 公式 API リファレンス「takeUntilDestroyed」 https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed
  • Angular 公式ドキュメント「RxJS interop with Angular signals」 https://angular.dev/ecosystem/rxjs-interop
  • Angular 公式ドキュメント「NG0203: inject() must be called from an injection context」 https://angular.dev/errors/NG0203
  • Angular 公式ドキュメント「Making HTTP requests」 https://angular.dev/guide/http/making-requests

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular unsubscribeの方法5つと要否早見表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?