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期連続の黒字決算。

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

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