ライフサイクルフック
Angularのライフサイクルフックは、Reactのクラスコンポーネントのライフサイクルメソッドに近い概念ですが、フック関数のuseEffectとは異なるアプローチです。
1. 主要なライフサイクルフック
Angular はコンポーネント(およびディレクティブ)の生成から破棄までの節目で、決まった名前のメソッドを呼び出します。クラスに対応するインターフェース(OnInit など)を implements し、メソッドを定義するだけで使えます。
呼び出し順序
| 順 | フック | タイミング | 回数 |
|---|---|---|---|
| 1 | constructor | インスタンス生成時。DI の注入とフィールド初期化 | 1 回 |
| 2 | ngOnChanges | 入力プロパティが変化したとき(初回は ngOnInit の前) | 変化のたび |
| 3 | ngOnInit | 入力の初期値が設定された後の初期化 | 1 回 |
| 4 | ngDoCheck | 変更検知のたび(独自の検知ロジック用) | 毎サイクル |
| 5 | ngAfterContentInit | 投影されたコンテンツ(ng-content)の初期化後 | 1 回 |
| 6 | ngAfterContentChecked | 投影コンテンツのチェック後 | 毎サイクル |
| 7 | ngAfterViewInit | 自身のビューと子ビューの初期化後 | 1 回 |
| 8 | ngAfterViewChecked | ビューのチェック後 | 毎サイクル |
| 9 | ngOnDestroy | 破棄の直前 | 1 回 |
ngOnInit
最もよく使うフックです。入力値が確定した後に実行されるため、初期データの取得や入力に依存した初期化に向いています。constructor は DI とフィールド初期化に留め、処理は ngOnInit に置くのが定石です。
@Component({
selector: 'app-user-detail',
template: `<p>{{ user()?.name }}</p>`,
})
export class UserDetailComponent implements OnInit {
userId = input.required<string>();
user = signal<User | null>(null);
private http = inject(HttpClient);
ngOnInit(): void {
this.http.get<User>(`/api/users/${this.userId()}`).subscribe((u) => this.user.set(u));
}
}
ngOnDestroy
コンポーネントが破棄される直前に呼ばれます。タイマー、イベントリスナー、WebSocket、RxJS の購読など、自動では片付かないものをここで解放します。
export class ClockComponent implements OnInit, OnDestroy {
now = signal(new Date());
private timerId?: ReturnType<typeof setInterval>;
ngOnInit(): void {
this.timerId = setInterval(() => this.now.set(new Date()), 1000);
}
ngOnDestroy(): void {
clearInterval(this.timerId);
}
}
ngOnChanges
@Input / input() のうち、親から渡される値が変化したときに、変更前後の値を SimpleChanges として受け取れます。
export class PriceComponent implements OnChanges {
@Input() amount = 0;
ngOnChanges(changes: SimpleChanges): void {
if (changes['amount'] && !changes['amount'].firstChange) {
console.log(`${changes['amount'].previousValue} -> ${changes['amount'].currentValue}`);
}
}
}
Signal ベースの input() を使う場合、ngOnChanges は呼ばれません(SimpleChanges を使わない設計)。代わりに computed() で派生値を作るか、effect() で副作用を実行します。新規コードではこちらが推奨です。
export class PriceComponent {
amount = input(0);
withTax = computed(() => Math.round(this.amount() * 1.1));
constructor() {
effect(() => console.log('amount changed:', this.amount()));
}
}
ngAfterViewInit
コンポーネント自身のビューと子コンポーネントの初期化が終わった後に呼ばれます。viewChild() / @ViewChild で取得した要素に、このタイミングで確実にアクセスできます。DOM を直接扱うサードパーティライブラリ(チャートなど)の初期化に使います。
export class ChartComponent implements AfterViewInit {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
ngAfterViewInit(): void {
new Chart(this.canvas().nativeElement, { /* ... */ });
}
}
ngAfterContentInit
ng-content で投影された子コンテンツ(contentChild() / @ContentChild)の初期化後に呼ばれます。タブやアコーディオンのような、親が子を受け取るコンポーネントで使います。
@Component({
selector: 'app-tabs',
template: `<ng-content />`,
})
export class TabsComponent implements AfterContentInit {
tabs = contentChildren(TabComponent);
ngAfterContentInit(): void {
this.tabs()[0]?.activate();
}
}
React との対比
| Angular | React(クラス) | React(関数) |
|---|---|---|
ngOnInit | componentDidMount | useEffect(fn, []) |
ngOnChanges | componentDidUpdate | 依存配列つき useEffect |
ngOnDestroy | componentWillUnmount | useEffect が返すクリーンアップ関数 |
ngAfterViewInit | componentDidMount(DOM 参照後) | useLayoutEffect / ref コールバック |
対応は完全には一致しません。React の useEffect は「描画のたびに依存配列を見て再実行する」副作用の宣言で、1 つのフックに初期化・更新・クリーンアップをまとめます。Angular は時点ごとにメソッドが分かれ、副作用の再実行はフックではなく Signal の effect() や RxJS で表現します。また Angular のフックはコンポーネントの 1 インスタンスに対して呼ばれ、再レンダリングのたびに関数本体が再実行されることはありません。
2. 変更検知(Change Detection)
変更検知は、アプリの状態の変化を DOM に反映する仕組みです。
Zone.js ベースの変更検知
従来の Angular は Zone.js が setTimeout、DOM イベント、Promise、XHR / fetch などの非同期 API をパッチし、完了のたびに「何か変わったかもしれない」と Angular に通知します。通知を受けた Angular は、コンポーネントツリーを根から順に走査し、テンプレートの式の値を前回と比較して DOM を更新します。
手軽ですが、無関係なイベント(マウス移動など)でもツリー全体が走査されるため、大規模アプリではコストになります。
OnPush 変更検知戦略
ChangeDetectionStrategy.OnPush を指定すると、そのコンポーネント(とその配下)は次のいずれかのときだけチェックされます。
- 入力プロパティの参照が変わった(オブジェクトの中身の変更は対象外)
- コンポーネント自身のテンプレートのイベントハンドラが実行された
- テンプレートで読み取っている Signal の値が変わった
asyncパイプが新しい値を発行したmarkForCheck()が呼ばれた
@Component({
selector: 'app-product-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h3>{{ product().name }}</h3><p>{{ count() }}</p>`,
})
export class ProductCardComponent {
product = input.required<Product>();
count = signal(0);
}
Signal を使えば OnPush の恩恵を受けやすく、新規コンポーネントでは OnPush を既定にするのが推奨です。
markForCheck() の使用
Signal や async パイプを使わず、OnPush のコンポーネントの内部状態を非同期に更新する場合は、ChangeDetectorRef.markForCheck() で「このコンポーネントを次回チェックしてほしい」と印を付けます。
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, /* ... */ })
export class LegacyComponent implements OnInit {
private cdr = inject(ChangeDetectorRef);
message = '';
ngOnInit(): void {
someLegacyApi.onMessage((m: string) => {
this.message = m;
this.cdr.markForCheck(); // 祖先を含めて「要チェック」にする
});
}
}
| メソッド | 動作 |
|---|---|
markForCheck() | 自身と祖先を「ダーティ」にし、次の変更検知サイクルでチェックさせる |
detectChanges() | その場で、このコンポーネントと子に対して変更検知を実行する |
detach() / reattach() | 変更検知ツリーから切り離す/戻す |
React の仮想 DOM との違い
React は状態が変わるとコンポーネント関数を再実行し、新旧の仮想 DOM を比較(diff)して実 DOM へ反映します。Angular はテンプレートをコンパイルして、式ごとの「前回値との比較と DOM 更新」命令を生成します。仮想 DOM は持たず、変わった式に対応する DOM ノードだけを直接更新します。
| 観点 | React | Angular |
|---|---|---|
| 再実行されるもの | コンポーネント関数全体 | テンプレートの更新命令のみ |
| 比較の単位 | 仮想 DOM ツリー | テンプレート式の値 |
| 最適化の手段 | memo / useMemo / useCallback | OnPush / Signals |
| 更新のきっかけ | setState の呼び出し | Zone.js の通知 / Signal の変更 |
Signals の導入により、Angular は「どのビューがどの Signal に依存しているか」を追跡できるようになり、ツリー全体の走査から、変更されたビューだけの更新へと向かっています。
3. Zoneless Angular(実験的)
Zone.js を使わずに変更検知を行うモードです。v18 で実験的に導入され、v20 で開発者プレビュー、その後の v20.2 で安定版になりました。Zone.js が不要になると、バンドルサイズの削減、スタックトレースの読みやすさ、async/await のネイティブ化、他のフレームワークとの共存のしやすさといった利点があります。
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [provideZonelessChangeDetection()],
};
あわせて、angular.json の polyfills から zone.js を削除します。
Zoneless では、次のいずれかが起きたときだけ変更検知がスケジュールされます。
- テンプレートで読み取っている Signal が更新された
markForCheck()が呼ばれたasyncパイプが新しい値を受け取った- テンプレートのイベントリスナーが実行された
ComponentRef.setInput()や、コンポーネントの生成・破棄が行われた
つまり、プレーンなプロパティを setTimeout の中で書き換えるだけでは画面が更新されません。状態は Signal で持つのが前提になります。
@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button (click)="inc()">{{ count() }}</button>`,
})
export class CounterComponent {
count = signal(0);
inc(): void {
this.count.update((n) => n + 1); // Signal の更新が再描画を引き起こす
}
}
OnPush と Signals で作られたコンポーネントは、ほぼそのまま Zoneless に移行できます。既存アプリでは、まず OnPush を徹底し、状態を Signal に置き換えていくのが移行の近道です。これは、React が「状態を useState 経由で更新したときだけ再描画する」モデルであるのと考え方が近づく変化と言えます。
4. DestroyRef と takeUntilDestroyed
DestroyRef は、コンポーネント、ディレクティブ、サービスなどの寿命の終わりに実行する処理を、クラスの外から登録できる API です(v16 で導入)。ngOnDestroy を実装する必要がなくなり、リソースの確保と解放を同じ場所に書けます。
export class ResizeWatcher {
private destroyRef = inject(DestroyRef);
constructor() {
const handler = () => console.log('resized');
window.addEventListener('resize', handler);
this.destroyRef.onDestroy(() => window.removeEventListener('resize', handler));
}
}
ngOnDestroy と違い、ヘルパー関数やサービスにも切り出せます。inject(DestroyRef) はインジェクションコンテキストで呼ぶ必要があります。
RxJS 購読の解除
従来は Subject を使った takeUntil パターンが定番でしたが、ボイラープレートが増えがちでした。
// 従来
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.service.data$.pipe(takeUntil(this.destroy$)).subscribe(/* ... */);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@angular/core/rxjs-interop の takeUntilDestroyed() を使うと、1 行で済みます。
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class OrdersComponent {
private service = inject(OrderService);
constructor() {
// コンストラクタ(インジェクションコンテキスト)内では引数不要
this.service.orders$.pipe(takeUntilDestroyed()).subscribe((o) => this.handle(o));
}
}
コンストラクタ以外(ngOnInit やメソッド内)で使うときは、DestroyRef を明示的に渡します。
export class OrdersComponent implements OnInit {
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.service.orders$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((o) => this.handle(o));
}
}
また、toSignal() は購読の解除を自動で行います。テンプレートで使うだけなら、subscribe せず toSignal(obs$) か async パイプを使うのが最も簡潔です。
orders = toSignal(this.service.orders$, { initialValue: [] });
React の useEffect が返すクリーンアップ関数に相当する役割を、Angular では DestroyRef.onDestroy() が担います。
5. afterNextRender / afterRender
Angular には、レンダリング完了後に DOM を安全に扱うためのフックがあります。ngAfterViewInit と違い、SSR(サーバーサイドレンダリング)時には実行されない点が重要です。サーバーには window や実際の DOM レイアウトがないため、ブラウザ API を使う処理をここに置きます。
| API | 実行タイミング |
|---|---|
afterNextRender | 次のレンダリング完了後に 1 回だけ |
afterEveryRender | レンダリングが完了するたびに(v19 までの名称は afterRender) |
どちらもブラウザでのみ実行され、サーバー側では何も行われません。インジェクションコンテキスト(コンストラクタやフィールド初期化)で呼び出します。
@Component({
selector: 'app-map',
template: `<div #host class="map"></div>`,
})
export class MapComponent {
host = viewChild.required<ElementRef<HTMLElement>>('host');
constructor() {
afterNextRender(() => {
// ブラウザでのみ実行される。window や DOM 計測を安全に使える
const map = new ThirdPartyMap(this.host().nativeElement);
inject(DestroyRef).onDestroy(() => map.dispose());
});
}
}
注意点として、inject() はコールバックの中では使えないため(インジェクションコンテキストの外)、DestroyRef などは事前に取得してください。
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const map = new ThirdPartyMap(this.host().nativeElement);
destroyRef.onDestroy(() => map.dispose());
});
}
DOM の読み取りと書き込みを分けて実行できるフェーズ指定もあります。レイアウトスラッシング(読み書きの交互実行による強制再計算)を避けるために使います。
afterEveryRender({
write: () => {
// DOM の書き込み
return this.el.nativeElement.offsetHeight; // 次のフェーズへ値を渡せる
},
read: (height) => {
// DOM の読み取り
console.log(height);
},
});
React の useEffect との対比
afterNextRender は、React で「マウント後にクライアントだけで実行される」useEffect(fn, []) に近い役割です。React の useEffect も SSR 時にはサーバー側で実行されません。
| 観点 | Angular の afterNextRender | React の useEffect |
|---|---|---|
| SSR 時 | 実行されない | 実行されない |
| 用途 | DOM 計測、ブラウザ専用ライブラリ | 副作用全般(取得、購読、DOM 操作) |
| 再実行 | 1 回のみ(毎回は afterEveryRender) | 依存配列の変化ごと |
| クリーンアップ | DestroyRef で別途登録 | 戻り値の関数 |
Angular では、データ取得やイベント購読などの副作用を useEffect に集約する代わりに、ngOnInit、effect()、DestroyRef、afterNextRender と用途で使い分けます。目安は次のとおりです。
- データ取得や初期化は
ngOnInit(またはresource()/toSignal()) - Signal の変化に応じた副作用は
effect() - 破棄時の後片付けは
DestroyRef.onDestroy()/takeUntilDestroyed() - ブラウザ専用の DOM 操作は
afterNextRender