Angular の変更検知(Change Detection)とは、コンポーネントの値とテンプレートを見比べて、変わったところだけ画面(DOM)を書き換える仕組みです。OnPush は「知らせを受けたコンポーネントだけを調べる」方式で、Angular v22 からは何も指定しないときの既定になりました。 そのため「値は変わっているのに画面が変わらない」ときは、Angular に変更が伝わっているかを確かめるのが近道です。

この記事では、Angular 22.2.1 の新規プロジェクトで OnPush のコンポーネントを実際に動かし、画面が更新される条件・されない条件を1つずつ確かめました。配列の push で表示が変わらない例と3つの直し方、markForCheck() と detectChanges() の違い、NG0100(ExpressionChangedAfterItHasBeenCheckedError)のエラー文と原因別の直し方を、テンプレートの評価回数のログつきで載せています。

確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular 22.2.1(@angular/core・@angular/cli)/ TypeScript 6.0.3 / RxJS 7.8.2 / Vitest 5.0.3(jsdom 30.1.1)。ng new で作った最小構成(zoneless・変更検知の指定なし)のまま、ng test と ng build で実行しました。NG0100 の1例は Chromium 141(Playwright 1.56.0)でも表示を確かめています。

Angular OnPush で画面が更新される条件の早見表

OnPush のコンポーネントで、どの操作が画面に反映されるかを一覧にしました。「結果」の列は、後半のコードを Angular 22.2.1 で動かして得たものです。

何をしたか 結果 理由 反映させる書き方
テンプレートのイベント((click) など)の中で値を変える 更新される イベントの処理で、そのコンポーネントと祖先に印が付く そのままで可
親から input に新しい値・新しい参照が渡る 更新される Object.is で比べて違えば印が付く そのままで可
テンプレートで読んでいる signal を set / update する 更新される signal がテンプレートに通知する そのままで可
async パイプで購読した Observable が値を流す 更新される async パイプが内部で markForCheck() を呼ぶ そのままで可
値を変えたあと markForCheck() を呼ぶ 更新される 自分と祖先に印を付け、次の変更検知を予約する そのままで可
配列に push する/オブジェクトの中身だけ書き換える(参照は同じ) 更新されない 参照が同じなので「変わっていない」と判定される 新しい配列・オブジェクトに差し替える
setTimeout や Promise の中で通常のプロパティに代入 更新されない Angular に何も伝わらない signal にする
subscribe の中で通常のプロパティに代入 更新されない 同上 async パイプ/toSignal()/markForCheck()
signal の中の配列に push して同じ配列を返す 更新されない signal も Object.is で比べる update(a => [...a, x])

覚え方は1つです。「Angular に伝わる操作(イベント・input の参照変更・signal・async パイプ・markForCheck)なら更新される。伝わらない代入は、次に何かが伝わるまで画面に出ない」。

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

(記載例)出典:株式会社RJC「Angular変更検知とOnPush|markForCheckの使い方」

変更検知と OnPush のしくみを短くつかむ

2つの方式:Eager と OnPush

Angular のコンポーネントは木の形に並んでいて、変更検知は根から順にテンプレートを評価していきます。方式は changeDetection で選びます。

方式 指定 調べる範囲 v22 での位置づけ
OnPush ChangeDetectionStrategy.OnPush 印(dirty)が付いたコンポーネントとその祖先だけ 指定を省いたときの既定
Eager ChangeDetectionStrategy.Eager(Default は同じ値の別名) 変更検知が走るたびに毎回 明示したときだけ

手元の @angular/core 22.2.1 の型定義でも、OnPush = 0、Eager = 1、Default = 1 と定義されていました。公式ドキュメント(Skipping component subtrees)にも、OnPush は v22 から既定の方式であると書かれています。

OnPush で印が付くタイミング

公式ドキュメントは、OnPush のコンポーネントで変更検知が走る場面を次の2つと説明しています。

  1. テンプレートのバインディングで新しい input を受け取ったとき(Object.is で前の値と比べる)
  2. そのコンポーネントか子孫でイベント(イベントバインディング・output・@HostListener)を処理したとき

これに加えて、zoneless のガイドでは Angular に変更を知らせる手段として、ChangeDetectorRef.markForCheck()(async パイプが自動で呼ぶ)、ComponentRef.setInput()、テンプレートで読まれている signal の更新、テンプレートやホストのリスナー、が挙げられています。早見表の「更新される」5行は、この一覧と対応しています。

zoneless では「知らせ」がないと変更検知そのものが始まらない

Angular v21 以降の新規プロジェクトは zoneless(zone.js を使わない構成)が既定です。zone.js ありの構成では setTimeout や Promise の完了をきっかけに変更検知が始まっていましたが、zoneless では上の「知らせ」が届いたときだけ変更検知が予約されます。v22 の既定(zoneless + OnPush)では、通知する書き方をそろえておくことがそのまま表示の正しさにつながります。

配列の push で画面が更新されない例を再現する

OnPush の質問でいちばん多いのがこの形です。親が配列を持ち、子が input で受け取って表示します。

再現コード(完成形)

src/app/cd1.spec.ts として保存し、ng test --watch=false --reporters=verbose で実行できます。trace は「テンプレートが評価された回数」を数えるための getter で、表示には影響しません。

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { TestBed } from '@angular/core/testing';

// 子:受け取った配列を表示するだけ(OnPush)
@Component({
  selector: 'app-item-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ items().join(',') }}{{ trace }}</p>`,
})
class ItemList {
  items = input<string[]>([]);
  renders = 0;
  // テンプレートが評価された回数を数える(表示には影響しない)
  get trace() { this.renders++; return ''; }
}

// 親:ボタンで配列に追加する
@Component({
  selector: 'app-parent',
  imports: [ItemList],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button id="push" (click)="addByPush()">push</button>
    <button id="replace" (click)="addByReplace()">差し替え</button>
    <app-item-list [items]="items" />`,
})
class Parent {
  items = ['A'];
  addByPush() { this.items.push('B'); }               // 同じ配列を書き換える
  addByReplace() { this.items = [...this.items, 'C']; } // 新しい配列に差し替える
}

it('配列の push と参照の差し替え', async () => {
  const fixture = TestBed.createComponent(Parent);
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;
  const child = fixture.debugElement.children[2].componentInstance as ItemList;
  const show = (label: string) =>
    console.log(`${label}: 画面="${el.querySelector('p')!.textContent}" 配列=${JSON.stringify(fixture.componentInstance.items)} 子の評価回数=${child.renders}`);

  show('初期表示');
  (el.querySelector('#push') as HTMLButtonElement).click();
  await fixture.whenStable();
  show('push 後');
  (el.querySelector('#replace') as HTMLButtonElement).click();
  await fixture.whenStable();
  show('差し替え後');
});

実行結果:

初期表示: 画面="A" 配列=["A"] 子の評価回数=1
push 後: 画面="A" 配列=["A","B"] 子の評価回数=1
差し替え後: 画面="A,B,C" 配列=["A","B","C"] 子の評価回数=2

なぜ push では更新されないのか

push のあと、配列の中身は ["A","B"] になっているのに、画面は A のまま、子のテンプレートの評価回数も 1 回のままです。 流れを順に追うと次のようになります。

  1. クリックのイベントで、親に印が付き、親のテンプレートが評価される
  2. 親は子の input に items を渡す。渡した配列は前回と同じ参照なので、Object.is(前, 今) は true
  3. 子には印が付かず、子のテンプレートは評価されない(=画面はそのまま)

差し替えのほうは [...this.items, 'C'] で新しい配列を作っているため、手順2で「違う値」と判定され、子が評価されます。そのとき、push で入れておいた B も一緒に表示されています。push した値は消えたわけではなく、画面に出るきっかけがなかっただけです。

直し方1:参照を差し替える(イミュータブルに更新する)

もっとも手軽な直し方は、配列やオブジェクトを書き換えずに、新しいものを作って代入することです。上の実行結果の3行目がその結果です。

やりたいこと 書き換える書き方(参照が同じ) 差し替える書き方(参照が変わる)
末尾に追加 list.push(x) list = [...list, x]
先頭に追加 list.unshift(x) list = [x, ...list]
削除 list.splice(i, 1) list = list.filter((_, idx) => idx !== i)
1件だけ変更 list[i].done = true list = list.map((v, idx) => idx === i ? { ...v, done: true } : v)
並べ替え list.sort(fn) list = list.toSorted(fn)(または [...list].sort(fn))
オブジェクトの項目変更 user.name = 'jiro' user = { ...user, name: 'jiro' }

オブジェクトでも同じことが起きます。後述の早見表の検証では、input に渡したオブジェクトの name だけを書き換えたとき画面は taro のままで、新しいオブジェクトを渡すと hanako に変わりました。

直し方2:signal で持つ

状態を signal にすると、値を変えた時点でテンプレートに通知が届くので、イベントの外(タイマーや通信の完了時)で変えても画面に反映されます。

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-signal-parent',
  imports: [ItemList],            // 上の ItemList をそのまま使う
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<app-item-list [items]="items()" />`,
})
class SignalParent {
  items = signal(['A']);
  addImmutable() { this.items.update((a) => [...a, 'B']); }             // 新しい配列を返す
  addMutable() { this.items.update((a) => { a.push('C'); return a; }); } // 同じ配列を返す
}

addImmutable() → addMutable() の順に、テストから直接(イベントを通さずに)呼んだ結果です。

初期表示: 画面="A" 配列=["A"] 子の評価回数=1
update で新しい配列: 画面="A,B" 配列=["A","B"] 子の評価回数=2
update で同じ配列に push: 画面="A,B" 配列=["A","B","C"] 子の評価回数=2

signal にしても「新しい配列を返す」ことは変わりません。 signal は既定で Object.is を使って前の値と比べるため、同じ配列を返すと「変わっていない」と扱われ、通知が出ません(3行目)。update((a) => [...a, x]) の形を習慣にしておくと安心です。

直し方3:markForCheck で知らせる

配列を持っているのが Angular の外のコードやサービスで、参照の差し替えにも signal にもすぐには変えにくい場合は、変更のタイミングで ChangeDetectorRef.markForCheck() を呼んで知らせます。

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Injectable, inject } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
class CartStore {
  items: string[] = ['A'];
  changed = new Subject<void>();
  add(v: string) { this.items.push(v); this.changed.next(); }
}

@Component({
  selector: 'app-cart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ store.items.join(',') }}{{ trace }}</p>`,
})
class Cart {
  store = inject(CartStore);
  private cdr = inject(ChangeDetectorRef);
  useMark = false;   // 比較のためのスイッチ
  renders = 0;
  get trace() { this.renders++; return ''; }
  constructor() {
    this.store.changed.subscribe(() => { if (this.useMark) this.cdr.markForCheck(); });
  }
}

useMark が false のまま add('B')、true にしてから add('C') を呼んだ結果です。

初期表示: 画面="A" 配列=["A"] 評価回数=1
markForCheck なしで push: 画面="A" 配列=["A","B"] 評価回数=1
markForCheck ありで push: 画面="A,B,C" 配列=["A","B","C"] 評価回数=2

markForCheck() を呼ぶと、同じ配列への push でも画面が更新されます。テンプレートが評価し直されるので、配列の中身がそのまま表示に出るためです。

3つの直し方の選び方

状況 向いている直し方
新しく書くコンポーネントの状態 signal(タイマーや通信の完了時でも通知が届く)
親から子へ渡すデータ 参照の差し替え(signal の update でも同じ考え方)
Observable をそのまま表示したい async パイプまたは toSignal()
外部ライブラリのコールバックなど、Angular の外で値が変わる markForCheck()
既存のコードで subscribe して代入している まず markForCheck() で動かし、順に signal / async パイプへ

OnPush で更新される条件を1つずつ確かめる

早見表の内容を、1つの OnPush コンポーネントで順番に確かめたコードです。

import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

@Component({
  selector: 'app-probe',
  imports: [AsyncPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button (click)="plain = 'click'">btn</button>
    <span id="plain">{{ plain }}</span>
    <span id="sig">{{ sig() }}</span>
    <span id="user">{{ user().name }}</span>
    <span id="async">{{ stream$ | async }}</span>
    <span id="sub">{{ fromSubscribe }}</span>`,
})
class Probe {
  cdr = inject(ChangeDetectorRef);
  user = input({ name: 'taro' });
  plain = 'init';
  sig = signal('init');
  stream$ = new Subject<string>();
  source$ = new Subject<string>();
  fromSubscribe = 'init';
  constructor() { this.source$.subscribe((v) => (this.fromSubscribe = v)); }
}

it('OnPush で更新される条件', async () => {
  const fixture = TestBed.createComponent(Probe);
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;
  const c = fixture.componentInstance;
  const text = (id: string) => el.querySelector('#' + id)!.textContent;
  const row = (label: string, id: string, expected: string) =>
    console.log(`${label} → 画面="${text(id)}" ${text(id) === expected ? '更新された' : '更新されない'}`);

  setTimeout(() => (c.plain = 'timer'), 10);
  await wait(30); await fixture.whenStable();
  row('1. setTimeout で通常のプロパティに代入', 'plain', 'timer');

  c.source$.next('subscribed');
  await wait(30); await fixture.whenStable();
  row('2. subscribe の中で通常のプロパティに代入', 'sub', 'subscribed');

  const user = { name: 'taro' };
  fixture.componentRef.setInput('user', user);
  await fixture.whenStable();
  user.name = 'jiro';
  await wait(30); await fixture.whenStable();
  row('3. input のオブジェクトの中身だけ書き換え', 'user', 'jiro');

  fixture.componentRef.setInput('user', { name: 'hanako' });
  await fixture.whenStable();
  row('4. input に新しいオブジェクトを渡す', 'user', 'hanako');
  console.log(`   (このとき 1 と 2 の値も画面に出る: plain="${text('plain')}" sub="${text('sub')}")`);

  el.querySelector('button')!.click();
  await fixture.whenStable();
  row('5. テンプレートのイベント (click) で代入', 'plain', 'click');

  setTimeout(() => c.sig.set('timer'), 10);
  await wait(30); await fixture.whenStable();
  row('6. setTimeout で signal を set', 'sig', 'timer');

  c.stream$.next('emitted');
  await fixture.whenStable();
  row('7. async パイプで購読した Observable が値を流す', 'async', 'emitted');

  c.plain = 'marked';
  c.cdr.markForCheck();
  await fixture.whenStable();
  row('8. 代入のあと markForCheck()', 'plain', 'marked');
});

実行結果:

1. setTimeout で通常のプロパティに代入 → 画面="init" 更新されない
2. subscribe の中で通常のプロパティに代入 → 画面="init" 更新されない
3. input のオブジェクトの中身だけ書き換え → 画面="taro" 更新されない
4. input に新しいオブジェクトを渡す → 画面="hanako" 更新された
   (このとき 1 と 2 の値も画面に出る: plain="timer" sub="subscribed")
5. テンプレートのイベント (click) で代入 → 画面="click" 更新された
6. setTimeout で signal を set → 画面="timer" 更新された
7. async パイプで購読した Observable が値を流す → 画面="emitted" 更新された
8. 代入のあと markForCheck() → 画面="marked" 更新された

読み取れることは3つあります。

  • 1〜3 は、プロパティの値は変わっているのに画面が変わらない。Angular に知らせが届いていないためです。
  • 4 で input が変わると、1 と 2 で入れておいた値もまとめて画面に出る。テンプレート全体が評価し直されるからです。「別の操作をしたら急に表示が変わった」という現象の正体はこれです。
  • 1 と 6 の違いは「通常のプロパティか、signal か」だけ。zoneless では setTimeout 自体は変更検知のきっかけにならず、signal の更新が通知の役目を果たします。

子が Eager のときは push でも更新される

同じ「親が push する」例で、子だけ ChangeDetectionStrategy.Eager にするとどうなるかも確かめました。

@Component({
  selector: 'app-eager-list',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>{{ items().join(',') }}</p>`,
})
class EagerList { items = input<string[]>([]); }

@Component({
  imports: [EagerList],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button (click)="items.push('B')">push</button><app-eager-list [items]="items" />`,
})
class EagerParent { items = ['A']; }
初期表示: 画面="A"
push 後 : 画面="A,B"

Eager の子は、親が評価されるたびに一緒に評価されるため、参照が同じでも push の結果が画面に出ます。v21 までの書き方(指定なし=毎回評価)で動いていたコードを v22 の新規プロジェクトに持ってくると表示が変わらなくなることがあるのは、この既定の違いによるものです。まず Eager を明示して動きをそろえ、そのあと参照の差し替えや signal に置き換えていく、という進め方ができます。

markForCheck と detectChanges の違い

ChangeDetectorRef には、画面を更新するためのメソッドが2つあります。名前は似ていますが、動き方が異なります。

動かして比べる

親 Panel と子 Badge(どちらも OnPush)を用意し、子の値を変えたあとにそれぞれのメソッドを呼びました。

@Component({
  selector: 'app-badge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<b>{{ label }}{{ trace }}</b>`,
})
class Badge {
  cdr = inject(ChangeDetectorRef);
  label = 'v1';
  renders = 0;
  get trace() { this.renders++; return ''; }
}

@Component({
  imports: [Badge],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<app-badge />{{ trace }}`,
})
class Panel {
  renders = 0;
  get trace() { this.renders++; return ''; }
}

it('markForCheck と detectChanges の違い', async () => {
  const fixture = TestBed.createComponent(Panel);
  await fixture.whenStable();
  const el: HTMLElement = fixture.nativeElement;
  const parent = fixture.componentInstance;
  const child = fixture.debugElement.children[0].componentInstance as Badge;
  const show = (label: string) =>
    console.log(`${label}: 画面="${el.querySelector('b')!.textContent}" 親の評価=${parent.renders}回 子の評価=${child.renders}回`);

  show('初期表示                 ');
  child.label = 'v2';
  child.cdr.markForCheck();
  show('markForCheck() の直後    ');
  await fixture.whenStable();
  show('次の変更検知が終わったあと');

  child.label = 'v3';
  child.cdr.detectChanges();
  show('detectChanges() の直後   ');
  await fixture.whenStable();
  show('whenStable のあと        ');
});

実行結果:

初期表示                 : 画面="v1" 親の評価=1回 子の評価=1回
markForCheck() の直後    : 画面="v1" 親の評価=1回 子の評価=1回
次の変更検知が終わったあと: 画面="v2" 親の評価=2回 子の評価=2回
detectChanges() の直後   : 画面="v3" 親の評価=2回 子の評価=3回
whenStable のあと        : 画面="v3" 親の評価=2回 子の評価=3回
  • markForCheck() は「印を付けて予約する」だけ。呼んだ直後の画面は v1 のままで、次の変更検知で v2 になります。そのとき親も一緒に評価されています(親 1→2 回)。
  • detectChanges() は「その場で、このコンポーネントと子だけを調べる」。呼んだ直後に画面が v3 になり、親は評価されていません(2 回のまま)。

比較表

観点 markForCheck() detectChanges()
することは 自分と祖先に印を付け、変更検知を予約する このビューと子のビューを今すぐ調べる
画面が変わるタイミング 次の変更検知のとき(非同期) 呼んだ直後(同期)
祖先のコンポーネント 一緒に評価される 評価されない
続けて3回呼ぶと(今回の確認) 評価は1回増えただけ(1→2回) 評価が3回増えた(2→5回)
向いている場面 Angular の外で値が変わったことを知らせる(基本はこちら) detach() したビューを自分の間合いで更新する、直後に DOM の寸法を測りたい
公式ドキュメントの説明 OnPush のとき、ビューを「変更あり」として明示的に印を付け、再び調べられるようにする ビューとその子を調べる。detach() と組み合わせて局所的な変更検知に使う

迷ったら markForCheck() を選びます。 続けて呼んでも評価は1回にまとまり、祖先を含めてそろった状態で描画されるからです。detectChanges() は呼んだ回数だけ評価が走るので、使う場所を絞るのがコツです。signal で状態を持てば、どちらも書かずに済む場面が増えます。

NG0100 ExpressionChangedAfterItHasBeenCheckedError の再現と直し方

どんなエラーか

開発モードの Angular は、変更検知が終わった直後にもう一度テンプレートを評価し、1回目と値が変わっていたら NG0100 を出します。公式ドキュメントにも、このエラーは開発モードでだけ投げられると書かれています。「一度の変更検知の途中で値が動いている」ことを教えてくれる合図で、表示と値の食い違いを開発中に見つけられます。

再現コード:3つの原因と直し方

確かめやすいよう、原因側のコンポーネントは ChangeDetectionStrategy.Eager にしています(理由は次の項で説明します)。run() は fixture.detectChanges() を呼び、エラーが出ればその文面を表示する補助関数です。

import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnInit, inject, signal, viewChild } from '@angular/core';
import { TestBed } from '@angular/core/testing';

function run(label: string, type: any) {
  const fixture = TestBed.createComponent(type);
  try {
    fixture.detectChanges();
    console.log(`${label}: エラーなし 画面="${(fixture.nativeElement as HTMLElement).textContent!.trim()}"`);
  } catch (e) {
    console.log(`${label}:\n${(e as Error).message}`);
  } finally {
    fixture.destroy();
  }
}
const Eager = ChangeDetectionStrategy.Eager;

// 原因1: 評価のたびに値が変わる
let seq = 0;
@Component({ selector: 'app-c1', changeDetection: Eager, template: `<p>ID: {{ id }}</p>` })
class Cause1 { get id() { return ++seq; } }
@Component({ selector: 'app-f1', changeDetection: Eager, template: `<p>ID: {{ id }}</p>` })
class Fix1 { readonly id = ++seq; }   // 作成時に1回だけ決める

// 原因2: 子が、すでに描画済みの親の値を書き換える
@Component({ selector: 'app-child2', template: `child` })
class Child2 implements OnInit {
  parent = inject(Cause2);
  ngOnInit() { this.parent.title = '読み込み完了'; }
}
@Component({ selector: 'app-c2', changeDetection: Eager, imports: [Child2], template: `<h1>{{ title }}</h1><app-child2 />` })
class Cause2 { title = '読み込み中'; }

@Component({ selector: 'app-child2f', template: `child` })
class Child2Fixed implements OnInit {
  parent = inject(Fix2);
  ngOnInit() { this.parent.title.set('読み込み完了'); }
}
@Component({ selector: 'app-f2', changeDetection: Eager, imports: [Child2Fixed], template: `<h1>{{ title() }}</h1><app-child2f />` })
class Fix2 { title = signal('読み込み中'); }   // signal にする

// 原因3: ngAfterViewInit で、テンプレートに出している値を変える
@Component({ selector: 'app-c3', changeDetection: Eager, template: `<div #box style="width:120px">box</div><p>幅: {{ width }}</p>` })
class Cause3 implements AfterViewInit {
  box = viewChild.required<ElementRef<HTMLElement>>('box');
  width = '不明';
  ngAfterViewInit() { this.width = this.box().nativeElement.style.width; }
}
@Component({ selector: 'app-f3', changeDetection: Eager, template: `<div #box style="width:120px">box</div><p>幅: {{ width() }}</p>` })
class Fix3 implements AfterViewInit {
  box = viewChild.required<ElementRef<HTMLElement>>('box');
  width = signal('不明');   // signal にする
  ngAfterViewInit() { this.width.set(this.box().nativeElement.style.width); }
}

it('NG0100 の再現と直し方', () => {
  run('原因1', Cause1); run('直し1', Fix1);
  run('原因2', Cause2); run('直し2', Fix2);
  run('原因3', Cause3); run('直し3', Fix3);
});

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

原因1:
NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '1'. Current value: '2'. Expression location: _Cause1 component. Find more at https://v22.angular.dev/errors/NG0100
直し1: エラーなし 画面="ID: 3"
原因2:
NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '読み込み中'. Current value: '読み込み完了'. Expression location: _Cause2 component. Find more at https://v22.angular.dev/errors/NG0100
直し2: エラーなし 画面="読み込み完了child"
原因3:
NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '不明'. Current value: '120px'. Expression location: _Cause3 component. Find more at https://v22.angular.dev/errors/NG0100
直し3: エラーなし 画面="box幅: 120px"

エラー文の読み方

エラー文の部分 意味 使い方
Previous value: '不明' 1回目の評価で画面に出した値 この文字列でテンプレートを検索すると場所を絞れる
Current value: '120px' 確認の評価で得られた値 どの処理がこの値を入れたかを探す
Expression location: _Cause3 component 値が変わった式があるコンポーネント 見るべきテンプレートが分かる

原因3と同じコンポーネントをアプリのルートに置いて ng build --configuration development し、Chromium 141 で開いたところ、コンソールに次の行が出て、画面は 幅: 不明 のままでした。

ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '不明'. Current value: '120px'. Expression location: _App component. Find more at https://v22.angular.dev/errors/NG0100

原因別の直し方

原因 よくある形 直し方
評価のたびに値が変わる テンプレートから Math.random()、new Date()、連番を返す getter やメソッドを呼ぶ 値を1回だけ計算してプロパティに持つ(直し1)。計算が必要なら computed()
子が親の描画済みの値を変える 子の ngOnInit や input の setter から、親やサービスの値を書き換える その値を signal にする(直し2)。または、親の側で先に値を決めてから子に渡す
描画後のフックで自分の値を変える ngAfterViewInit / ngAfterViewChecked で、表示中のプロパティに代入 signal にする(直し3)。DOM に依存しない値なら ngOnInit やコンストラクターへ移す
読み込み中フラグを途中で切り替える サービスの loading を、描画の途中で同期的に true → false にする フラグを signal にする/async パイプで表示する

公式ドキュメントの NG0100 のページでも、評価のたびに違う値を返すメソッドや getter、子が親の値を変えるケース、ngAfterViewInit などのフックが原因として挙げられ、ngOnInit や ngAfterContentInit など適切なフックへ移すこと、コンストラクターで初期値を決めることが案内されています。

直し2・直し3で signal が効くのは、signal の更新がテンプレートへの通知になり、同じ変更検知の中でもう一度そのビューが評価されるためです。値の食い違いが残らないので、確認の評価でも同じ値になります。

v22 の既定(OnPush)では NG0100 が出ずに表示が残ることがある

原因3のコンポーネントから changeDetection: Eager の行を外し、v22 の既定(OnPush)で動かした結果です。

// 原因3 と同じ内容で、変更検知の指定を省いたもの(v22 の既定 = OnPush)
@Component({ selector: 'app-c3d', template: `<div #box style="width:120px">box</div><p>幅: {{ width }}</p>` })
class Cause3Default implements AfterViewInit {
  box = viewChild.required<ElementRef<HTMLElement>>('box');
  width = '不明';
  ngAfterViewInit() { this.width = this.box().nativeElement.style.width; }
}

it('OnPush(既定)のとき', async () => {
  run('既定(OnPush)', Cause3Default);
  const f = TestBed.createComponent(Cause3Default);
  await f.whenStable();
  console.log(`whenStable で待った場合: 画面="${(f.nativeElement as HTMLElement).textContent!.trim()}" プロパティ=${f.componentInstance.width}`);
});
既定(OnPush): エラーなし 画面="box幅: 不明"
whenStable で待った場合: 画面="box幅: 不明" プロパティ=120px

エラーは出ませんが、プロパティは 120px なのに画面は 不明 のままです。OnPush のビューは印が付いていなければ確認の評価でも対象にならないため、食い違いが検出されません。「NG0100 は出ていないのに表示が合わない」ときは、この形を疑ってみてください。

見つけるには、provideCheckNoChangesConfig({ exhaustive: true }) を使います。zoneless のガイドで紹介されている設定で、確認の評価のときだけ、すべてのビューを Eager と同じように調べます。

import { provideCheckNoChangesConfig } from '@angular/core';

it('exhaustive を有効にしたとき', () => {
  TestBed.configureTestingModule({ providers: [provideCheckNoChangesConfig({ exhaustive: true })] });
  run('既定(OnPush)+exhaustive', Cause3Default);
});
既定(OnPush)+exhaustive:
NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '不明'. Current value: '120px'. Expression location: _Cause3Default component. Find more at https://v22.angular.dev/errors/NG0100

同じコンポーネントで NG0100 が出るようになりました。アプリ全体で使うときは、app.config.ts の providers に provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) のように interval(ミリ秒)を付けると、定期的に確認が走ります。

signal と zoneless での変更検知の考え方

ここまでの結果を、v22 の既定(zoneless + OnPush)での書き方としてまとめます。

場面 通常のプロパティ signal
テンプレートのイベントで変更 更新される 更新される
setTimeout・Promise・通信の完了時に変更 更新されない(markForCheck() が必要) 更新される
subscribe の中で変更 更新されない(markForCheck() が必要) 更新される
描画後のフックで変更 画面に出ない/Eager では NG0100 更新される(エラーなし)
配列・オブジェクトの中身だけ変更 更新されない 更新されない(新しい参照を返す)

signal を状態の置き場所にすると、「どこで変えたか」を気にせずに済みます。 残る注意点は「配列やオブジェクトは新しい参照にする」の1点だけです。

ng build の結果

この記事のコンポーネントを置いたプロジェクトを、既定の設定(zoneless・zone.js なし)で ng build した結果です。

Initial chunk files | Names         |  Raw size | Estimated transfer size
main-7WH5IZZI.js    | main          | 198.87 kB |                54.31 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes

                    | Initial total | 198.87 kB |                54.31 kB

画面が更新されないときのチェックリスト

順番 確かめること 見つかったら
1 値そのものは変わっているか(console.log で確認) 変わっていなければ処理の側を見直す
2 別の操作(クリックなど)をすると表示が変わるか 変わるなら「通知が届いていない」形。3 以降へ
3 配列の push・splice・sort、オブジェクトの項目代入をしていないか 新しい参照に差し替える
4 setTimeout・Promise・subscribe の中で通常のプロパティに代入していないか signal/async パイプ/toSignal() にする
5 外部ライブラリのコールバックで値を変えていないか markForCheck() を呼ぶ
6 描画後のフック(ngAfterViewInit など)で値を変えていないか signal にする、または ngOnInit へ移す
7 通知漏れをまとめて見つけたい provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) を開発中だけ入れる

よくある質問

Q. OnPush にすると何がうれしいのですか? A. 印の付いたコンポーネントとその祖先だけを評価するので、テンプレートの評価回数が減ります。今回の例でも、push のとき子の評価回数は 1 回のままでした。大きな一覧や、頻繁に値が変わる画面で効果が出やすい方式です。v22 では指定を省くと OnPush になります。

Q. markForCheck() を呼んだのに、直後に DOM を読んでも値が変わっていません。 A. markForCheck() は予約だけを行い、画面が変わるのは次の変更検知のときです(実行結果でも直後は v1 のままでした)。直後に DOM を読みたい場合は detectChanges() を使います(実行結果では、呼んだ直後に v3 になりました)。

Q. NG0100 は本番でも出ますか? A. 公式ドキュメントによると、開発モードでだけ投げられます。エラー文は開発中にだけ見える手がかりなので、出たときに原因別の表に沿って直しておくと安心です。

Q. setTimeout(() => ..., 0) で囲むと NG0100 が消えると聞きました。 A. 原因3の代入を setTimeout(() => ..., 0) で囲んで動かしたところ、NG0100 は出なくなりましたが、プロパティが 120px になっても画面は 幅: 不明 のままでした。zoneless では setTimeout の中の代入が Angular に伝わらないためです。signal にしておけば、ずらす必要もなく、表示も更新されます(直し3)。

まとめ

  • OnPush は「知らせが届いたコンポーネントだけ評価する」方式で、v22 では指定を省いたときの既定。
  • 更新されるのは、テンプレートのイベント・input の新しい参照・signal の更新・async パイプ・markForCheck() の5つ。
  • 配列の push やオブジェクトの項目代入は参照が同じなので伝わらない。新しい配列・オブジェクトに差し替える(signal の update でも同じ)。
  • markForCheck() は予約(祖先も一緒に評価)、detectChanges() はその場で自分と子だけ評価。基本は markForCheck()。
  • NG0100 は「1回の変更検知の途中で値が変わった」合図。値を1回だけ決める、signal にする、適切なフックへ移す、で直せる。
  • 既定の OnPush では NG0100 が出ずに表示だけ残ることがある。provideCheckNoChangesConfig({ exhaustive: true }) で見つけられる。
  • zoneless では signal を状態の置き場所にすると、タイマーや通信の完了時の変更もそのまま画面に届く。

参考・出典

  • Angular 公式ドキュメント「Skipping component subtrees」 https://angular.dev/best-practices/skipping-subtrees
  • Angular 公式ドキュメント「Angular without ZoneJS (Zoneless)」 https://angular.dev/guide/zoneless
  • Angular 公式ドキュメント「NG0100: Expression has changed after it was checked」 https://angular.dev/errors/NG0100
  • Angular 公式 API リファレンス「ChangeDetectorRef」 https://angular.dev/api/core/ChangeDetectorRef

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

(記載例)出典:株式会社RJC「Angular変更検知とOnPush|markForCheckの使い方」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?