Angular中級 #4 コンポーネントライフサイクルフック

読了 8分

前回 は RxJS を扱いつつ最後に「購読はいつか整理しなければならない」という話を短くしました。その「いつか」が正確にいつなのか、そしてそれ以外にもコンポーネントが生きている間に私たちが挟み込める地点がどこにあるのかを、今回本格的に扱ってみましょう。

Angular コンポーネントは、ただ画面に描かれて消えるだけではありません。生まれ、入力を受け取り、描かれ、また入力を受け取り、整理され、消える という一連の段階を経ます。この段階ごとに Angular が私たちが定義したメソッドを呼んでくれるのが ライフサイクルフック (lifecycle hooks) です。

ライフサイクルの全体像 #

まずは流れを 1 枚で整理してみます。

コンポーネントライフサイクルの流れ
constructor()           // クラス生成 (DI を受け取る)
ngOnChanges()           // 入力値が初めてセットされる (以降も入力変化のたびに)
ngOnInit()              // 初期化。入力が使用可能
ngDoCheck()             // 変更検知のたびに呼ばれる (頻繁)
ngAfterContentInit()    // <ng-content> で入ってきた子が準備される
ngAfterViewInit()       // 自分のテンプレートが描かれる。ViewChild 使用可能
   ▼ (変更検知サイクルごとに ngOnChanges 以下が繰り返される)
ngOnDestroy()           // コンポーネント削除。整理 (cleanup) のタイミング

複雑に見えますが、実務でよく使うものは指折り数えられます。ngOnInitngOnDestroyngOnChangesngAfterViewInit のこの 4 つを先に身につければ、9 割は解決します。

ngOnInit — もっともよく使うフック #

最初に、そしてもっともよく出会うフックが ngOnInit です。コンポーネントの 初期化ロジック を置くところです。

src/app/article.component.ts
import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'app-article',
  standalone: true,
  template: `<h1>{{ title }}</h1>`,
})
export class ArticleComponent implements OnInit {
  @Input() articleId!: string;
  title = '';

  constructor() {
    // articleId はまだ undefined!
    console.log('constructor:', this.articleId);
  }

  ngOnInit() {
    // ここでは articleId の値が入っている
    this.loadArticle(this.articleId);
  }

  private loadArticle(id: string) { /* ... */ }
}

ここでよく出る質問 — 「constructor でやってはダメなんですか?」 2 つは役割が違います。

  • constructor — クラスのインスタンスを作るところ。依存性注入 (DI) を受け取る用途にだけ 使うのがよいです。この時点では、まだ @Input() (または input()) で入ってくる値がセットされていません。
  • ngOnInit — 入力値がすべてセットされた直後に 1 回呼ばれます。入力を使用しなければならない初期化はここに 置きます。

ルールとして覚えておきましょう: コンストラクタには inject() だけ、入力を使う初期化は ngOnInit に。

ヒント
Angular 17 以降の input() シグナルは、入力がシグナルなのでコンポーネントのどこからでも自然に読めるため、ngOnInit の負担がさらに減ります。それでも「一度だけ実行する非同期初期化」は依然として ngOnInit が自然なところです。

ngOnDestroy — 整理のタイミング #

コンポーネントがツリーから取り除かれる直前に呼ばれます。外部リソースとの接続を切るところ です。

src/app/clock.component.ts
export class ClockComponent implements OnInit, OnDestroy {
  time = '';
  private intervalId?: number;

  ngOnInit() {
    this.intervalId = window.setInterval(() => {
      this.time = new Date().toLocaleTimeString();
    }, 1000);
  }

  ngOnDestroy() {
    if (this.intervalId !== undefined) clearInterval(this.intervalId);
  }
}

setInterval を整理しないと、コンポーネントが消えた後もコールバックが回り続けてメモリリークを生みます。RxJS の購読、WebSocket 接続、グローバルイベントリスナー — 外部に紐を結んでおいたものはすべてここで解かなければなりません。

ただし、モダンな Angular では ngOnDestroy を直接実装する場面が次第に減っています。よりすっきりした道具が入ってきたからです。

DestroyRef と takeUntilDestroyed — モダンな cleanup パターン #

Angular 16 から入った DestroyRef は、「このコンポーネントが破棄されるときに実行するコールバック」を登録 できるようにしてくれます。そして RxJS と結合した takeUntilDestroyed 演算子が、購読の整理を 1 行で処理してくれます。

src/app/search.component.ts
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
  selector: 'app-search',
  standalone: true,
  template: `<p>tick: {{ count }}</p>`,
})
export class SearchComponent implements OnInit {
  private destroyRef = inject(DestroyRef);
  count = 0;

  ngOnInit() {
    interval(1000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(n => (this.count = n));
  }
}

takeUntilDestroyed は、コンポーネントが破棄される瞬間に自動で購読を整理してくれます。OnDestroy インターフェースを実装する必要も、Subject を別途作る必要もありません。

DestroyRef は RxJS 以外の一般的な整理にも使えます。destroyRef.onDestroy(callback) の 1 行で整理ロジックを登録できるので、OnDestroy インターフェースを実装しなくてもよいのです。

src/app/listener.component.ts
ngOnInit() {
  const onScroll = () => (this.scrollY = window.scrollY);
  window.addEventListener('scroll', onScroll);

  this.destroyRef.onDestroy(() => {
    window.removeEventListener('scroll', onScroll);
  });
}

新しいコードであれば、このパターンを優先して検討 してください。

ngOnChanges — 入力が変わるたびに #

@Input() (または新しい input()) で入ってくる値が変わるたびに呼ばれます。どの入力が何に変わったかを SimpleChanges オブジェクトで受け取ります。

src/app/chart.component.ts
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-chart',
  standalone: true,
  template: `<canvas #c></canvas>`,
})
export class ChartComponent implements OnChanges {
  @Input() data: number[] = [];
  @Input() color = 'blue';

  ngOnChanges(changes: SimpleChanges) {
    if (changes['data']) {
      console.log('以前:', changes['data'].previousValue);
      console.log('現在:', changes['data'].currentValue);
      this.redraw();
    }
    // color だけ変わったときは redraw を省略
  }

  private redraw() { /* ... */ }
}

ngOnChangesngOnInit の直前に最初の呼び出しが 1 回 余分に起きます (最初の入力セット)。以降は入力が変わるたびに呼ばれます。

ただし input() シグナルを使うコードであれば、ngOnChanges の代わりに effect()computed() で入力変化に反応するほうが自然です (下で扱います)。

ngAfterViewInit — DOM アクセスが可能になる時点 #

ngOnInit は入力は使用できますが、自分のテンプレートがまだ DOM に描かれる前 です。ViewChild で指した子コンポーネントや DOM エレメントは、この時点では undefined でありえます。自分のテンプレートが描かれた後に呼ばれるのが ngAfterViewInit です。

src/app/auto-focus-input.component.ts
@Component({
  selector: 'app-auto-focus-input',
  standalone: true,
  template: `<input #box type="text" />`,
})
export class AutoFocusInputComponent implements AfterViewInit {
  @ViewChild('box') box!: ElementRef<HTMLInputElement>;

  ngAfterViewInit() {
    this.box.nativeElement.focus();
  }
}

ngOnInit で同じことを試みると undefined エラーに出会います。DOM に直接手を出したり、子コンポーネントのメソッドを呼ばなければならないなら、ngAfterViewInit が正解です。

注記
Angular 17+ では viewChild() シグナル関数が新たに入って @ViewChild デコレータを置き換えます。シグナルなので ngAfterViewInit を使わずに effect() で変化をつかめます。ただしデコレータ方式のコードもまだしばらく出会うことになるので、両方を知っておくのがよいでしょう。

残りのフック — 短く #

実務でほぼ出会いませんが、名前は知っておくとよいです。

  • ngDoCheck — 変更検知サイクルのたびに毎回呼ばれます。呼び出しが多いので、重いロジックは禁物。Angular が自動でつかめない変化 (たとえば配列の内部の修正) を直接検出する必要があるときにだけ使います。
  • ngAfterContentInit / ngAfterContentChecked<ng-content> で投影 (projected) されたコンテンツが準備・検査された直後。ContentChild クエリを扱うときに時々出会います。

99% のコンポーネントはこの 3 つに触れることがないので、「こんなのがあったな」程度に知っておけば十分です。

Signals 時代のライフサイクル — effect() #

Angular 16 から入った Signals は、ライフサイクルの扱い方そのものを変えています。中核は effect() です。

src/app/counter.component.ts
export class CounterComponent {
  count = signal(0);

  constructor() {
    effect(() => {
      console.log('count が変わりました:', this.count());
      document.title = `Count: ${this.count()}`;
    });
  }
}

effect()自分が読んだシグナルが変わるたびに自動で再実行 されます。ngOnInit で 1 回実行し、ngOnChanges で再実行し、ngOnDestroy で整理する流れが、1 行で終わってしまいます。

また effect() は、コンポーネント (あるいは登録されたインジェクションコンテキスト) が破棄されると 自動で整理 されます。DestroyRef を別途用意する必要がありません。

input() シグナルと一緒に使えば、ngOnChanges もほぼ置き換えられます。

src/app/chart-signal.component.ts
@Component({ /* ... */ })
export class ChartSignalComponent {
  data = input<number[]>([]);
  color = input<string>('blue');

  constructor() {
    effect(() => {
      // data や color が変わるたびに自動実行
      this.redraw(this.data(), this.color());
    });
  }

  private redraw(data: number[], color: string) { /* ... */ }
}

新しいコードであれば、できるだけシグナル + effect() の組み合わせに行くのがトレンドです。ただし、既存コードの ngOnInit/ngOnDestroy をわざわざすべて剥がさなければならないという意味ではありません。新たに書く部分から自然に変えていけば構いません。

よくあるミス #

ライフサイクルフックの周りで、最初に陥りやすい落とし穴です。

1. ngOnInit で非同期の結果を待たない #

ngOnInitasync にしても、Angular はそれを待ってくれません。await が終わる前に次のライフサイクルが進みます。レンダリングがデータを待つようにするには、シグナルや @if で「データの到着可否」を判定するパターンを使ってください。

@if (user(); as u) {
  <p>{{ u.name }}</p>
} @else {
  <p>ロード中...</p>
}

2. ngOnInit で ViewChild にアクセス #

ngOnInit 時点ではまだ自分のテンプレートが描かれていないので、ViewChildundefined でありえます。DOM や子コンポーネントへのアクセスは ngAfterViewInit (またはシグナルベースの viewChild() + effect()) で。

3. OnPush モードとライフサイクル呼び出し頻度 #

ChangeDetectionStrategy.OnPush でコンポーネントを作ると、変更検知がはるかに少なく回り、ngDoCheck/ngAfterContentChecked などの呼び出しも減ります。こういったフックにビジネスロジックを依存させないほうが安全です。

4. constructor で重い仕事をする #

constructor は DI を受け取るところであって、初期化ロジックのところではありません。HTTP 呼び出しやタイマー登録のような副作用は、ngOnInit または effect() に移してください。

まとめ #

今回の記事では、コンポーネントのライフサイクルを最初から最後まで見ていき、その上に挟み込むフックたちを扱いました。整理すると:

  • ライフサイクルの流れ: 生成 → 入力 → 初期化 → レンダー → (繰り返し) → 破棄
  • もっともよく使うフック: ngOnInitngOnDestroyngOnChangesngAfterViewInit
  • constructor には DI だけ、入力を使用する初期化は ngOnInit
  • 整理 (cleanup) は ngOnDestroy よりも DestroyRef + takeUntilDestroyed がすっきり
  • DOM/ViewChild アクセスは ngAfterViewInit 以降から
  • Signals 時代では effect() がライフサイクルの多くの部分を置き換え

次の記事である「Angular中級 #5 Standalone と Lazy Loading」では、NgModule なしで動作する standalone コンポーネントの構造をより深く見て、ルート単位でコードを細かく分けて初期ロードを速くする lazy loading パターンを扱います。

X