- Angular
- TypeScript
- 技術 💻
Angular zoneless移行手順|v22実測と更新されない3例の直し方
Angular の zoneless(ゾーンレス)とは、zone.js を使わずに「signal の更新」「テンプレートのイベント」「markForCheck()」などの明示的な通知だけで変更検知を動かす仕組みです。 Angular v21 以降の新規プロジェクトでは zoneless が既定になり、v22 ではコンポーネントの既定の変更検知が OnPush になりました。移行は provideZonelessChangeDetection() への置き換えと zone.js の削除だけで済みますが、zone.js が「なんとなく」拾っていた画面更新を、コード側で通知する形に直す作業が本体です。
この記事では、Angular 22.2.0 で zone.js ありのアプリ(v20 相当の設定)を実際に作り、zoneless へ1ステップずつ移行しました。ng build の初期バンドルサイズ、画面が更新されなくなる3つの典型パターンを Playwright で再現した結果、signal / markForCheck / AsyncPipe で直した前後、fakeAsync が動かなくなったときの実際のエラー文と Vitest での書き換えまで、すべて実行結果つきで載せています。
Angular zoneless 移行の早見表
| やりたいこと | 書くこと・すること | 補足 |
|---|---|---|
| zoneless を有効にする | provideZonelessChangeDetection() を providers に追加し、provideZoneChangeDetection() を削除 |
v21 以降は provider を書かなくても zoneless が既定 |
| zone.js をバンドルから外す | angular.json の polyfills から "zone.js" を削除し、npm uninstall zone.js |
今回の実測で Initial total が 約38kB(転送量 約12kB)減少 |
| setTimeout・Promise の中で値を変える | 普通のプロパティ → signal にして .set() |
通知が自動で届く |
| 外部ライブラリのコールバックで値を変える | ChangeDetectorRef.markForCheck() を呼ぶ(または signal) |
Angular を知らないコードからの変更 |
subscribe の中で代入している |
AsyncPipe か toSignal() に置き換える |
購読解除も自動 |
| 通知漏れを開発中に見つける | provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) |
漏れがあると NG0100 がコンソールに出る |
NgZone.onStable などを使っている |
afterNextRender() / afterEveryRender() に置き換え |
NgZone.run()・runOutsideAngular() はそのままで可 |
fakeAsync / tick のテスト |
Vitest のフェイクタイマー+TestBed.tick()、または実タイマー+await fixture.whenStable() |
zone.js を外すと fakeAsync はエラー |
| 全コンポーネントを zoneless 向きにする | ChangeDetectionStrategy.OnPush(v22 は未指定で OnPush) |
Eager(旧 Default)のままだと通知漏れに気づきにくい |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular zoneless移行手順|v22実測と更新されない3例の直し方」
zoneless と zone.js の違い:何が画面更新のきっかけになるか
zone.js は setTimeout・Promise・DOM イベントなどのブラウザ API を上書き(モンキーパッチ)し、「非同期処理が終わったら、とりあえずアプリ全体をチェックする」仕組みです。 便利な反面、何も変わっていなくてもチェックが走り、zone.js 自体のコード(今回の実測で polyfills 35.88kB)も初期読み込みに入ります。
zoneless では、Angular が「変わった」と知らされたときだけ変更検知が動きます。 公式ドキュメントが挙げている通知のきっかけは次のとおりです。
| 変更検知のきっかけ | zone.js あり | zoneless |
|---|---|---|
テンプレートの (click) などのイベント |
動く | 動く |
テンプレートで読んでいる signal の更新 |
動く | 動く |
ChangeDetectorRef.markForCheck() |
動く | 動く |
ComponentRef.setInput() |
動く | 動く |
AsyncPipe が新しい値を受け取る |
動く | 動く(内部で markForCheck) |
| setTimeout / Promise / HttpClient の完了 | 動く | 動かない |
addEventListener で直接つけたリスナー |
動く | 動かない |
flowchart LR
A[setTimeout・HTTP・外部ライブラリの<br>コールバックで値を変更] --> B{Angularに通知した?}
B -- signal.set / markForCheck / AsyncPipe --> C[変更検知が予約される]
B -- 普通のプロパティに代入しただけ --> D[画面はそのまま<br>次に別の通知が来たときにまとめて反映]
C --> E[画面が更新される]
バージョンごとの既定値
| バージョン | 新規プロジェクトの変更検知 | コンポーネントの既定戦略 | 主な API |
|---|---|---|---|
| v20 | zone.js(provideZoneChangeDetection) |
Default |
provideZonelessChangeDetection() が登場(v20.2 で安定版) |
| v21 | zoneless が既定 | Default |
v21.2 で ChangeDetectionStrategy.Eager 追加 |
| v22 | zoneless が既定 | OnPush が既定 |
Default は非推奨(Eager と同じ意味) |
ChangeDetectionStrategy の API リファレンスでは、Default は「Eager を使ってください。削除予定です」と非推奨になっています。v22 へ ng update すると、戦略が未指定のコンポーネントには Eager が付いて動作が変わらないように移行されると解説されています(この自動移行は本記事では未実行です)。
検証環境
| 項目 | バージョン |
|---|---|
| Angular(core / cli / build) | 22.2.0 |
| zone.js | 0.16.3 |
| TypeScript | 6.0 |
| Vitest | 5.0.2(ng test の既定ランナー) |
| Node.js | 24.21.0(Angular CLI 22 は Node 22.22.3 以上が必要) |
| Playwright / Chromium | 1.56.1 / 141 |
プロジェクトは次のコマンドで作りました。--zoneless=false を付けると、v20 と同じ zone.js ありの構成になります。
npx -y -p @angular/cli@22 ng new zone-app --defaults --skip-git --style=css --routing=false --ssr=false --ai-config=none --zoneless=false
生成された app.config.ts には provideZoneChangeDetection({ eventCoalescing: true }) が入り、angular.json の polyfills に "zone.js" が入ります。API は同じオリジンの /api/tasks をローカルの Node サーバー(ビルド成果物の配信も兼用)で返しています。
手順1:移行前のアプリ(zone.js あり)を用意する
zoneless で画面が更新されなくなる典型パターンを、あえて v20 までよく見かけた書き方でそろえました。すべて ChangeDetectionStrategy.Eager(旧 Default) です。
// src/app/third-party-widget.ts
// Angular を知らない外部ライブラリの例(自前の DOM とイベントを持つウィジェット)
export function createStarRating(host: HTMLElement, onChange: (value: number) => void) {
for (let i = 1; i <= 5; i++) {
const star = document.createElement('button');
star.textContent = '★';
star.dataset['star'] = String(i);
star.addEventListener('click', () => onChange(i)); // ネイティブのリスナー
host.appendChild(star);
}
}
// src/app/demos.ts(移行前)
import {
AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, OnInit, inject, viewChild,
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { createStarRating } from './third-party-widget';
interface Task { id: number; title: string; done: boolean }
// パターン1: setTimeout で普通のプロパティを書き換える
@Component({
selector: 'app-timer-message',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<p data-testid="timer">{{ message }}</p>`,
})
export class TimerMessage implements OnInit {
message = '保存中...';
ngOnInit() {
setTimeout(() => (this.message = '保存しました'), 500);
}
}
// パターン2: サードパーティのコールバック
@Component({
selector: 'app-rating',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<div #host></div><p data-testid="rating">評価: {{ rating }}</p>`,
})
export class Rating implements AfterViewInit {
host = viewChild.required<ElementRef<HTMLElement>>('host');
rating = 0;
ngAfterViewInit() {
createStarRating(this.host().nativeElement, (v) => (this.rating = v));
}
}
// パターン3: subscribe の中で直接代入
@Component({
selector: 'app-task-list',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<p data-testid="tasks">件数: {{ tasks.length }}</p>`,
})
export class TaskList implements OnInit {
private http = inject(HttpClient);
tasks: Task[] = [];
ngOnInit() {
this.http.get<Task[]>('/api/tasks').subscribe((t) => (this.tasks = t));
}
}
// 参考: テンプレートのイベントで書き換える(zoneless でもそのまま動く)
@Component({
selector: 'app-click-counter',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<button data-testid="inc" (click)="count = count + 1">+1</button>
<p data-testid="count">クリック: {{ count }}</p>`,
})
export class ClickCounter {
count = 0;
}
// 参考: changeDetection 未指定(v22 では OnPush 扱い)
@Component({
selector: 'app-default-timer',
template: `<p data-testid="default-timer">{{ message }}</p>`,
})
export class DefaultTimer implements OnInit {
message = '保存中...';
ngOnInit() {
setTimeout(() => (this.message = '保存しました'), 500);
}
}
// src/app/app.config.ts(移行前)
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
],
};
Playwright で「表示から1.5秒後」「★4をクリック後」「+1をクリック後」の表示を読み取りました。
1.5秒後 { timer: '保存しました', rating: '評価: 0', tasks: '件数: 3', count: 'クリック: 0', 'default-timer': '保存中...' }
★4クリック後 { timer: '保存しました', rating: '評価: 4', tasks: '件数: 3', count: 'クリック: 0', 'default-timer': '保存中...' }
+1クリック後 { timer: '保存しました', rating: '評価: 4', tasks: '件数: 3', count: 'クリック: 1', 'default-timer': '保存中...' }
zone.js ありなら、Eager の3パターンはすべて期待どおりに更新されます。 一方、changeDetection 未指定の DefaultTimer は zone.js があっても「保存中...」のままでした。v22 では未指定=OnPush なので、普通のプロパティへの代入では「このコンポーネントを確認して」という印が付かないためです。v22 で新しく書くコンポーネントは、zone.js の有無にかかわらず signal 前提で書くと覚えておくと迷いません。
手順2:provideZonelessChangeDetection に切り替える
// src/app/app.config.ts(移行後)
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideHttpClient(),
],
};
v21 以降は provideZoneChangeDetection() を消すだけでも zoneless になりますが、意図を明確にするため provideZonelessChangeDetection() を書いておくと読み手に親切です。NgModule で起動している場合は、@NgModule({ providers: [provideZonelessChangeDetection()] }) に書きます。
手順3:zone.js を polyfills から外してアンインストールする
// angular.json(build.options)
"polyfills": [
"zone.js"
],
この polyfills を削除し(他の polyfill がなければキーごと削除)、パッケージも外します。テスト用に zone.js/testing を入れている場合はそれも削除します。
npm uninstall zone.js
ng build の初期バンドル実測
各段階で ng build(production)の出力を記録しました。
【移行前(zone.js あり)】
Initial chunk files | Names | Raw size | Estimated transfer size
main-FOXC2TCK.js | main | 146.34 kB | 42.35 kB
polyfills-LVNOU2XZ.js | polyfills | 35.88 kB | 11.64 kB
| Initial total | 182.22 kB | 53.99 kB
【手順3のあと(zone.js 削除)】
Initial chunk files | Names | Raw size | Estimated transfer size
main-V5IPGIPB.js | main | 143.85 kB | 41.97 kB
| Initial total | 143.85 kB | 41.97 kB
| 段階 | Initial total(Raw) | 転送量の目安 |
|---|---|---|
| 移行前(zone.js あり) | 182.22 kB | 53.99 kB |
| 手順2のみ(provider 切り替え、polyfills は残す) | 180.03 kB | 53.64 kB |
| 手順3(polyfills から zone.js 削除) | 143.85 kB | 41.97 kB |
| 3パターンを直した後(後述) | 146.52 kB | 42.65 kB |
参考:ng new 直後(zone.js あり → zoneless) |
164.58 kB → 126.21 kB | 49.59 kB → 37.48 kB |
サイズが減るのは polyfills から zone.js を外したときです。provider を切り替えただけでは zone.js は読み込まれたままなので、手順3まで行って初めて約38kB(転送量で約12kB)減ります。
画面が更新されなくなる3つの典型パターン
手順3まで終えた状態(コードは移行前のまま)で、同じ Playwright スクリプトを実行した結果です。
1.5秒後 { timer: '保存中...', rating: '評価: 0', tasks: '件数: 0', count: 'クリック: 0', 'default-timer': '保存中...' }
★4クリック後 { timer: '保存中...', rating: '評価: 0', tasks: '件数: 0', count: 'クリック: 0', 'default-timer': '保存中...' }
+1クリック後 { timer: '保存しました', rating: '評価: 4', tasks: '件数: 3', count: 'クリック: 1', 'default-timer': '保存中...' }
setTimeout・外部ライブラリ・HTTP の結果が表示されず、テンプレートの (click) が起きた瞬間にまとめて反映されました。 「別のボタンを押すと直る」「たまに更新される」という症状は、この通知漏れが原因です。(click) は zoneless でも通知になるので、そのついでに Eager コンポーネントが再チェックされたわけです。OnPush の DefaultTimer はそれでも更新されません。
| パターン | 症状 | 直し方 | 選ぶ基準 |
|---|---|---|---|
| setTimeout / Promise で代入 | 他の操作をするまで古い表示 | signal にする | 基本はこれ |
| 外部ライブラリのコールバックで代入 | 同上 | markForCheck() または signal |
既存のプロパティを変えたくないとき markForCheck |
subscribe の中で代入 |
同上 | AsyncPipe または toSignal() |
テンプレートで使うなら AsyncPipe、TS でも使うなら toSignal |
パターン1:setTimeout で書き換える → signal にする
// 修正前
message = '保存中...';
ngOnInit() {
setTimeout(() => (this.message = '保存しました'), 500);
}
// 修正後
@Component({
selector: 'app-timer-message',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p data-testid="timer">{{ message() }}</p>`,
})
export class TimerMessage implements OnInit {
message = signal('保存中...');
ngOnInit() {
setTimeout(() => this.message.set('保存しました'), 500);
}
}
テンプレートで読んでいる signal を .set() すると、それ自体が Angular への通知になります。 テンプレート側は message → message() に変わる点に注意してください。
パターン2:外部ライブラリのコールバック → markForCheck()
// 修正後
@Component({
selector: 'app-rating',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div #host></div><p data-testid="rating">評価: {{ rating }}</p>`,
})
export class Rating implements AfterViewInit {
private cdr = inject(ChangeDetectorRef);
host = viewChild.required<ElementRef<HTMLElement>>('host');
rating = 0;
ngAfterViewInit() {
createStarRating(this.host().nativeElement, (v) => {
this.rating = v;
this.cdr.markForCheck(); // 「このビューを次回チェックして」と Angular に通知
});
}
}
markForCheck() は「このコンポーネント(と親)を次の変更検知で確認して」という印を付け、zoneless では変更検知の予約も行います。 地図・グラフ・エディタなど、自前でイベントを持つライブラリをラップするときの定番です。もちろん rating = signal(0) にして this.rating.set(v) と書いても直ります。
パターン3:subscribe の中で代入 → AsyncPipe
// 修正後
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-task-list',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AsyncPipe],
template: `<p data-testid="tasks">件数: {{ (tasks$ | async)?.length ?? 0 }}</p>`,
})
export class TaskList {
tasks$ = inject(HttpClient).get<Task[]>('/api/tasks');
}
AsyncPipe は値を受け取るたびに内部で markForCheck() を呼ぶので、zoneless でもそのまま動きます。 購読解除も自動です。TypeScript 側でも値を使うなら tasks = toSignal(this.http.get<Task[]>('/api/tasks'), { initialValue: [] })、ID に応じて取り直すなら httpResource が向いています。
修正後の結果
DefaultTimer も signal に直し、全コンポーネントを OnPush にしてから再実行しました。
1.5秒後 { timer: '保存しました', rating: '評価: 0', tasks: '件数: 3', count: 'クリック: 0', 'default-timer': '保存しました' }
★4クリック後 { timer: '保存しました', rating: '評価: 4', tasks: '件数: 3', count: 'クリック: 0', 'default-timer': '保存しました' }
+1クリック後 { timer: '保存しました', rating: '評価: 4', tasks: '件数: 3', count: 'クリック: 1', 'default-timer': '保存しました' }
すべての表示が、きっかけとなる出来事の直後に更新されるようになりました。
通知漏れを開発中に見つける:provideCheckNoChangesConfig
画面を1つずつ触って確かめるのは大変なので、開発ビルドでは「通知なしに値が変わっていないか」を定期的に調べる設定が使えます。
import {
ApplicationConfig, provideBrowserGlobalErrorListeners, provideCheckNoChangesConfig,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }), // 開発時だけ
provideHttpClient(),
],
};
修正前のコードで ng build --configuration development し、ブラウザのコンソールを Playwright で取得した結果です。
[console.error] ERROR RuntimeError: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '保存中...'. Current value: '保存しました'. Expression location: _TimerMessage component. Find more at https://v22.angular.dev/errors/NG0100
エラーに通知漏れのあるコンポーネント名(_TimerMessage)と、変わる前後の値が出るので、直す場所がすぐわかります。今回の実行では1秒ごとに同じ TimerMessage のエラーが出ており、1件直すと次の漏れが見える、という順で進めるとよいでしょう。本番用の設定には入れないでください。
NgZone API の置き換え表
| 使っている API | zoneless での扱い | 置き換え先 |
|---|---|---|
NgZone.onStable / onMicrotaskEmpty / onUnstable |
発火しない | afterNextRender()(1回)/ afterEveryRender()(毎回) |
NgZone.isStable |
常に true |
状態を signal で持つ、DOM の変化は MutationObserver |
NgZone.run() / runOutsideAngular() |
そのまま使える | 削除しなくてよい |
Reactive Forms の setValue / patchValue |
変更検知のきっかけにならない | valueChanges で markForCheck()、または signal で持つ |
| SSR で非同期処理の完了を待たせたい | zone.js がないので待てない | PendingTasks.run()、RxJS は pendingUntilEvent() |
// 例:描画後に一度だけ DOM を測る(onStable の置き換え)
import { Component, ElementRef, afterNextRender, inject } from '@angular/core';
@Component({ selector: 'app-box', template: `<div>内容</div>` })
export class Box {
private el = inject(ElementRef<HTMLElement>);
constructor() {
afterNextRender(() => {
console.log('高さ', this.el.nativeElement.offsetHeight);
});
}
}
テストの移行:fakeAsync と whenStable
zone.js を外すと fakeAsync はエラーになる
移行前によくある fakeAsync + tick のテストです。
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { TimerMessage } from './demos';
describe('TimerMessage(zone.js 前提の書き方)', () => {
it('fakeAsync + tick で 500ms 進める', fakeAsync(() => {
const fixture = TestBed.createComponent(TimerMessage);
fixture.detectChanges();
tick(500);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('保存しました');
}));
});
zone.js を外した状態で ng test を実行した結果です。
FAIL |zone-app| src/app/demos.spec.ts [ src/app/demos.spec.ts ]
Error: zone-testing.js is needed for the fakeAsync() test helper but could not be found.
Please make sure that your environment includes zone.js/testing
なお、Vitest(v22 の既定ランナー)では zone.js を読み込んでいても、そのままでは fakeAsync が動きません。 ng new --zoneless=false 直後の構成で実行すると Error: Expected to be running in 'ProxyZone', but it was not found. になりました。移行途中で既存テストを動かし続けたい場合は、テスト用のビルド構成に Vitest 用パッチを入れると通りました(test ターゲットには polyfills オプションがないため buildTarget 経由で指定します)。
// angular.json(移行期間だけの暫定設定)
"build": {
"configurations": {
"test": { "polyfills": ["zone.js", "zone.js/testing", "zone.js/plugins/vitest-patch"] }
}
},
"test": {
"builder": "@angular/build:unit-test",
"options": { "buildTarget": "zone-app:build:test" }
}
公式の Vitest 移行ガイドも、この方法を案内しつつ「ネイティブの async と Vitest のフェイクタイマーへ書き換える計画を」と勧めています。
zoneless 向けの書き方(実行して通ったもの)
// src/app/demos.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskList, TimerMessage } from './demos';
describe('TimerMessage(zoneless)', () => {
afterEach(() => vi.useRealTimers());
it('フェイクタイマー + TestBed.tick()', () => {
vi.useFakeTimers();
const fixture = TestBed.createComponent(TimerMessage);
TestBed.tick();
expect(fixture.nativeElement.textContent).toContain('保存中...');
vi.advanceTimersByTime(500);
TestBed.tick();
expect(fixture.nativeElement.textContent).toContain('保存しました');
});
it('実タイマー + whenStable', async () => {
const fixture = TestBed.createComponent(TimerMessage);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('保存中...');
await new Promise((r) => setTimeout(r, 500));
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('保存しました');
});
});
describe('TaskList(HTTP)', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
});
it('レスポンス後に件数が表示される', async () => {
const fixture = TestBed.createComponent(TaskList);
TestBed.tick(); // 初回描画 → AsyncPipe が購読してリクエストが出る
const http = TestBed.inject(HttpTestingController);
http.expectOne('/api/tasks').flush([
{ id: 1, title: 'A', done: false },
{ id: 2, title: 'B', done: true },
]);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('件数: 2');
http.verify();
});
});
✓ |zone-app| src/app/demos.spec.ts > TimerMessage(zoneless) > フェイクタイマー + TestBed.tick() 41ms
✓ |zone-app| src/app/demos.spec.ts > TimerMessage(zoneless) > 実タイマー + whenStable 509ms
✓ |zone-app| src/app/demos.spec.ts > TaskList(HTTP) > レスポンス後に件数が表示される 8ms
Tests 3 passed (3)
テスト移行でつまずいた点(実測)
| 書き方 | 結果 | 理由と対策 |
|---|---|---|
vi.useFakeTimers() 中に await fixture.whenStable() |
5秒でタイムアウト | zoneless の変更検知の予約もタイマーを使うため、偽のタイマーでは進まない。フェイクタイマー中は TestBed.tick() で同期的に描画する |
フェイクタイマーで advanceTimersByTimeAsync(500) だけ |
表示が「保存中...」のまま | 500ms ちょうどでは描画予約がまだ実行されていない。TestBed.tick() を足すか、少し長めに進める(550ms で通過) |
HTTP 待ちの前に await fixture.whenStable() |
タイムアウト | 保留中の HTTP があると安定しない。先に expectOne().flush() してから whenStable |
Eager + 普通のプロパティのまま detectChanges() |
NG0100: ExpressionChangedAfterItHasBeenCheckedError |
zoneless の TestBed は通知漏れをエラーにしてくれる。signal などに直す |
最後の行は、移行前の TimerMessage をそのままテストしたときの実際の出力です。
Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: '保存中...'. Current value: '保存しました'. Expression location: _LegacyTimer component.
テストが NG0100 で落ちるのは「本番でも画面が更新されない場所」のサインなので、テストを直すのではなくコンポーネントを直すのが正解です。
zoneless 移行チェックリスト
| # | 確認項目 | 確認方法 |
|---|---|---|
| 1 | Angular を v20 以上(できれば v22)に上げた | ng version |
| 2 | provideZoneChangeDetection() を削除し provideZonelessChangeDetection() を追加 |
app.config.ts / NgModule |
| 3 | polyfills から zone.js と zone.js/testing を削除 |
angular.json / polyfills.ts |
| 4 | npm uninstall zone.js |
package.json |
| 5 | setTimeout / Promise / subscribe で代入しているプロパティを signal・AsyncPipe に | grep -n "subscribe(" src -r などで洗い出す |
| 6 | 外部ライブラリのコールバックに markForCheck() か signal |
ラッパーコンポーネントを確認 |
| 7 | NgZone.onStable / onMicrotaskEmpty / isStable を置き換え |
afterNextRender / afterEveryRender |
| 8 | Reactive Forms の値変更で表示が変わる箇所を確認 | valueChanges + markForCheck |
| 9 | 開発時に provideCheckNoChangesConfig({ exhaustive: true }) で NG0100 が出ない |
ブラウザのコンソール |
| 10 | fakeAsync を Vitest のフェイクタイマー / whenStable に書き換え |
ng test |
| 11 | SSR を使っているなら非同期処理を PendingTasks で包む |
サーバー出力に値が入っているか |
| 12 | Initial total が減ったことを確認 | ng build の出力表 |
一括で洗い出したい場合は、Angular CLI の MCP サーバー(ng mcp)に onpush_zoneless_migration というツールが含まれており、AI エディタから OnPush・zoneless 化の手順を案内させることもできます(本記事では未実行)。
よくある質問
Q. zoneless にすると何がうれしいのですか? A. 今回の実測では、初期バンドルが約38kB(転送量で約12kB)減りました。加えて、変更検知が「通知があったときだけ」動くので無駄なチェックが減り、スタックトレースに zone.js が挟まらずデバッグしやすくなります。
Q. OnPush にしないと zoneless は使えませんか? A. 必須ではありません。今回の結果のように Eager(旧 Default)のままでも動きますが、通知漏れがあると「他の操作のついでに更新される」ため気づきにくくなります。公式も OnPush を推奨しており、v22 では未指定で OnPush になります。
Q. NgZone.run() や runOutsideAngular() は削除する必要がありますか?
A. いいえ。公式ドキュメントで zoneless と互換があると明記されており、残しておいて問題ありません。削除が必要なのは onStable・onMicrotaskEmpty・onUnstable・isStable に依存する処理です。
Q. v21 以降で新規作成したプロジェクトでも、この記事の内容は関係ありますか? A. 新規プロジェクトは最初から zoneless なので手順1〜3は不要です。ただし、「subscribe の中で代入」「外部ライブラリのコールバック」で画面が更新されないパターンはそのまま当てはまるので、直し方の表とテストの書き方が役立ちます。
まとめ
- zoneless は「通知があったときだけ」変更検知する方式で、v21 から新規プロジェクトの既定、v22 ではコンポーネントも OnPush が既定
- 移行は
provideZonelessChangeDetection()への切り替え+polyfills から zone.js 削除。サイズが減るのは zone.js を外したとき(実測 182.22kB → 143.85kB) - setTimeout・外部ライブラリ・subscribe 代入は、他の操作をするまで画面が更新されなくなる
- 直し方は signal / markForCheck() / AsyncPipe(toSignal) の3つで、基本は signal
provideCheckNoChangesConfig({ exhaustive: true })で通知漏れを NG0100 として見つけられる- テストは fakeAsync → Vitest のフェイクタイマー+
TestBed.tick()、または実タイマー+whenStable()へ - フェイクタイマー中の
whenStable()や、保留中 HTTP がある状態のwhenStable()は待ち続けるので順番に注意
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular zoneless移行手順|v22実測と更新されない3例の直し方」
株式会社RJCでは、フレームワークの新しい仕組みを実際に手元で動かして確かめ、その結果をチームで共有しながら開発に取り入れています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。