- Angular
Angular CanDeactivateガード|未保存の離脱確認と戻るボタン
Angular で「保存していない変更があります。移動しますか?」と確認するには、関数型ガード CanDeactivateFn をルートの canDeactivate に登録します。 ガードが false を返すと画面の移動が取り消され、true なら移動します。
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
component.hasUnsavedChanges()
? confirm('保存していない変更があります。このページを離れますか?')
: true;
ただし、ガードが止められるのは Angular のルーター(Router)を通る移動だけです。 再読み込み・タブを閉じる・URL の直接入力はルーターを通らないため、ブラウザの beforeunload イベントと組み合わせます。
| 離れ方 | 止める仕組み | 出た確認(Chromium 141) |
|---|---|---|
routerLink のリンクをクリック |
CanDeactivate ガード | ガードの confirm(自分の文言) |
| ブラウザの「戻る」 | CanDeactivate ガード | ガードの confirm。2回目は設定しだい(後述) |
| 再読み込み(F5) | beforeunload |
ブラウザ標準の確認(文言は変えられない) |
| タブ・ウィンドウを閉じる | beforeunload |
ブラウザ標準の確認 |
| アプリの外の URL へ移動 | beforeunload |
ブラウザ標準の確認 |
確認環境:Angular 22.2.2(@angular/router 22.2.2)・TypeScript 6.0.3・Node.js 24.21.0 で ng build した本番ビルドを、Chromium 141(playwright-core 1.64.0)で操作しました。表の「出た確認」は、Playwright の dialog イベントで受け取ったダイアログの種類と文言です。仕様は Angular 公式ドキュメント(angular.dev)と MDN で確かめました(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular CanDeactivateガード|未保存の離脱確認と戻るボタン」
完成コード:ガード・画面・ルートの3ファイル
フォームに変更があるときだけ確認を出す、そのまま使える形です。どの画面でも使えるように、ガードはインターフェース HasUnsavedChanges を通して画面に問い合わせます。
1. ガード(unsaved-changes-guard.ts)
import { CanDeactivateFn } from '@angular/router';
/** 離脱確認をしたい画面が実装するインターフェース */
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
/** 未保存の変更があれば confirm で確かめる関数型ガード */
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) => {
if (!component.hasUnsavedChanges()) {
return true;
}
return confirm('保存していない変更があります。このページを離れますか?');
};
2. 画面(profile-edit.ts)
リアクティブフォームの dirty(一度でも入力されたか)で「未保存の変更」を判断します。保存したら markAsPristine() で「変更なし」に戻すので、保存後の移動では確認が出ません。
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HasUnsavedChanges } from './unsaved-changes-guard';
@Component({
selector: 'app-profile-edit',
imports: [ReactiveFormsModule],
host: {
// 再読み込み・タブを閉じる・アプリの外へ移動するときの確認
'(window:beforeunload)': 'onBeforeUnload($event)',
},
template: `
<h2>プロフィール編集</h2>
<form [formGroup]="form" (ngSubmit)="save()">
<label>名前 <input formControlName="name" /></label>
<label>メール <input formControlName="email" /></label>
<button type="submit">保存</button>
</form>
<p>{{ form.dirty ? '未保存の変更あり' : '保存済み' }}</p>
`,
})
export class ProfileEdit implements HasUnsavedChanges {
private router = inject(Router);
form = new FormGroup({
name: new FormControl('山田 太郎', { nonNullable: true }),
email: new FormControl('taro@example.com', { nonNullable: true }),
});
hasUnsavedChanges(): boolean {
return this.form.dirty;
}
save(): void {
// ここで API に送る。成功したら「変更なし」に戻してから移動する
this.form.markAsPristine();
this.router.navigateByUrl('/');
}
onBeforeUnload(event: BeforeUnloadEvent): void {
if (this.hasUnsavedChanges()) {
event.preventDefault();
event.returnValue = ''; // Chrome/Edge 118 以前向け
}
}
}
host の '(window:beforeunload)' は、@HostListener('window:beforeunload', ['$event']) と同じ働きです。
3. ルート(app.routes.ts)と設定(app.config.ts)
// app.routes.ts
import { Routes } from '@angular/router';
import { ProfileEdit } from './profile-edit';
import { unsavedChangesGuard } from './unsaved-changes-guard';
export const routes: Routes = [
{ path: 'profile/edit', component: ProfileEdit, canDeactivate: [unsavedChangesGuard] },
];
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withRouterConfig } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
// 'computed' は「戻る」を取り消したときの履歴のずれを防ぐ設定(後述)
provideRouter(routes, withRouterConfig({ canceledNavigationResolution: 'computed' })),
],
};
ルートの canDeactivate は配列です。複数のガードを並べると、すべてが true を返したときだけ移動します。
動かした結果:どの離れ方で何が出たか
上のコードを本番ビルドして、Chromium で操作しました。
| 操作 | ダイアログ | 結果 |
|---|---|---|
| 変更なしでリンクをクリック | 出ない | 移動した |
| 変更ありでリンク → キャンセル | confirm「保存していない変更があります。…」 |
移動しない。入力した値はそのまま |
| 変更ありでリンク → OK | confirm |
移動した |
| 保存してから移動 | 出ない | 移動した(markAsPristine() のため) |
| 変更ありで再読み込み → キャンセル | beforeunload(文言は空) |
再読み込みされず、入力した値が残った |
| 変更ありで再読み込み → OK | beforeunload |
再読み込みされ、入力は初期値に戻った |
| 変更なしで再読み込み | 出ない | 再読み込みされた |
| 変更ありでタブを閉じる → キャンセル | beforeunload |
タブは閉じなかった |
| 自作ダイアログ版(後述)で「残る」→「破棄して移動」 | 画面内のダイアログ | 「残る」で移動せず、「破棄して移動」で移動した |
beforeunload のダイアログの文言は、Playwright では空文字で届きました。ブラウザが決めた文言が表示され、ページ側から変えることはできません(MDN に「Only show a generic browser-specified string」とあります)。
CanDeactivateFn の型と戻り値
CanDeactivateFn<T> の型は、Angular のドキュメントでは次のとおりです。
type CanDeactivateFn<T> = (
component: T, // 離れようとしている画面のインスタンス
currentRoute: ActivatedRouteSnapshot, // 今のルート
currentState: RouterStateSnapshot, // 今の URL の状態
nextState: RouterStateSnapshot, // 移動先の URL の状態
) => MaybeAsync<GuardResult>;
戻り値は、すぐ返す値でも、Promise や Observable でもかまわない作りです(MaybeAsync)。
| 返す値 | ルーターの動き |
|---|---|
true |
移動する |
false |
移動を取り消す |
UrlTree / RedirectCommand |
移動を取り消し、その URL へ移動し直す |
Promise<boolean> / Observable<boolean> |
最初に出た値で決める(Observable はそこで購読をやめる) |
nextState.url を見れば移動先が分かるので、「ログアウト画面へ行くときだけは確認しない」といった分け方もできます。
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component, _route, _state, nextState) => {
if (nextState.url.startsWith('/logout') || !component.hasUnsavedChanges()) {
return true;
}
return confirm('保存していない変更があります。このページを離れますか?');
};
ng generate guard で作る
Angular CLI 22.2.2 で ng generate guard unsaved-changes --implements CanDeactivate を実行すると、次のファイル(unsaved-changes-guard.ts)ができました。既定で関数型(--functional の既定値が true)です。
import { CanDeactivateFn } from '@angular/router';
export const unsavedChangesGuard: CanDeactivateFn<unknown> = (component, currentRoute, currentState, nextState) => {
return true;
};
unknown の部分を、画面のクラスかインターフェース(上の HasUnsavedChanges)に書き換えて使います。
beforeunload:ルーターを通らない離脱を止める
再読み込み・タブを閉じる・アプリ外の URL への移動では、ルーターのガードは呼ばれません。 ページそのものが破棄されるためです。そこでブラウザの beforeunload イベントを使います。MDN が勧める書き方は次の2点です。
event.preventDefault()を呼ぶ(推奨)- 互換のため
event.returnValueにも値を入れる(MDN の例のコメントでは Chrome/Edge 119 より前のため)
MDN では、ブラウザごとの決まりとして次の点も挙げられています。
| 決まり | 内容 |
|---|---|
| 文言 | ブラウザの決まった文言だけ。ページから変えられない |
| ユーザー操作 | そのページで一度も操作(クリック・入力など)がないと、ダイアログは出ない |
| bfcache | Firefox では beforeunload を登録したページは「戻る・進む」用のキャッシュに入らない。変更があるときだけ登録するのがよい |
ユーザーが入力したあとに確認が出るという流れなので、入力のある編集画面では、この決まりで困ることは少ないはずです。bfcache への影響を避けたい場合は、hasUnsavedChanges() が true になったときだけ window.addEventListener('beforeunload', ...) し、保存したら removeEventListener する形にします。
ブラウザの「戻る」は canceledNavigationResolution: 'computed' にする
ブラウザの「戻る」でガードがキャンセルされたとき、既定の設定('replace')では履歴がずれます。 2つの設定で同じ操作をしました。操作は「ホーム → リンクで編集画面へ → 入力 → 戻る(キャンセル)→ 戻る(キャンセル)→ 戻る(OK)」です。
| 操作 | 'replace'(既定) |
'computed' |
|---|---|---|
| 戻る 1回目 → キャンセル | ガードの confirm。編集画面に残る |
ガードの confirm。編集画面に残る |
| 戻る 2回目 → キャンセル | ガードではなく beforeunload が出た |
ガードの confirm。編集画面に残る |
| 戻る 3回目 → OK | アプリの外(開く前のページ)へ出た | ホーム(/)へ戻った |
既定の 'replace' は、Angular のドキュメントでは「取り消したときに、移動前のルーターの状態を location.replaceState で書き込む」動きです。1回目の「戻る」でブラウザの履歴は1つ前(ホーム)の位置に移ったまま、その場所の URL が編集画面に書き換えられます。そのため2回目の「戻る」で、アプリより前の履歴へ出ようとしました。
'computed' にすると、ルーターが取り消したぶん履歴を進め直します。 ドキュメントには「戻るボタンで移動が取り消されたら、ルーターは前へ進む移動を行う」とあります。何回「戻る」を押してもガードの確認が出て、OK でホームへ戻りました。
provideRouter(routes, withRouterConfig({ canceledNavigationResolution: 'computed' }))
ドキュメントには、URL の一部だけを扱う UrlHandlingStrategy とは組み合わせられない、という注意もあります。
confirm の代わりに自作ダイアログを出す(Promise を返す)
ガードは Promise<boolean> も返せるので、画面内のダイアログで「残る/破棄して移動」を選ばせることもできます。 Angular Material の MatDialog を使う場合も、閉じたときの値(afterClosed() の Observable)を返す形で同じように書けます。ここでは依存のない最小の形です。
// unsaved-changes-guard.ts に追加
import { Observable } from 'rxjs';
export interface CanLeave {
canLeave(): boolean | Promise<boolean> | Observable<boolean>;
}
export const canLeaveGuard: CanDeactivateFn<CanLeave> = (component) => component.canLeave();
// confirm-dialog.ts
import { Component, Injectable, inject, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ConfirmDialogService {
readonly message = signal<string | null>(null);
private resolve?: (ok: boolean) => void;
open(message: string): Promise<boolean> {
this.message.set(message);
return new Promise<boolean>((resolve) => (this.resolve = resolve));
}
close(ok: boolean): void {
this.message.set(null);
this.resolve?.(ok);
}
}
@Component({
selector: 'app-confirm-dialog',
template: `
@if (dialog.message(); as message) {
<div class="backdrop">
<div class="dialog" role="dialog" aria-modal="true">
<p>{{ message }}</p>
<button (click)="dialog.close(false)">このページに残る</button>
<button (click)="dialog.close(true)">破棄して移動</button>
</div>
</div>
}
`,
styles: `
.backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .4); display: grid; place-items: center; }
.dialog { background: #fff; padding: 1.5rem; border-radius: 8px; }
`,
})
export class ConfirmDialog {
dialog = inject(ConfirmDialogService);
}
// article-edit.ts
import { Component, inject, signal } from '@angular/core';
import { CanLeave } from './unsaved-changes-guard';
import { ConfirmDialogService } from './confirm-dialog';
@Component({
selector: 'app-article-edit',
template: `<textarea [value]="body()" (input)="onInput($event)"></textarea>`,
})
export class ArticleEdit implements CanLeave {
private dialog = inject(ConfirmDialogService);
private saved = '下書き';
body = signal(this.saved);
onInput(e: Event): void {
this.body.set((e.target as HTMLTextAreaElement).value);
}
canLeave(): boolean | Promise<boolean> {
if (this.body() === this.saved) {
return true;
}
return this.dialog.open('保存していない変更があります。破棄して移動しますか?');
}
}
<app-confirm-dialog /> はルートの App コンポーネントのテンプレートに1つ置き、ルートに canDeactivate: [canLeaveGuard] を付けます。動かすと、リンクをクリックした時点で画面内にダイアログが出て、ボタンを押すまでルーターは待ちました。
beforeunload のダイアログは自作できません。 再読み込みやタブを閉じる操作には、ブラウザ標準の確認が出ます。
よくある質問
Q. クラスで書くガード(implements CanDeactivate)は使えなくなりましたか。
A. Angular 22 のドキュメントでも CanDeactivate インターフェースは stable(安定版)の扱いで、廃止の表示はありません。CLI の既定とドキュメントの例は関数型です。サービスを使いたいときは、関数型ガードの中で inject() を呼べます。
Q. 確認を出したくない移動(保存後・ログアウト)があります。
A. 保存後はフォームを markAsPristine() してから移動すると、ガードが true を返します。移動先で分けるなら、4番目の引数 nextState.url を見ます。
Q. 子ルートの画面にもガードは効きますか。
A. canDeactivate は、そのルートの画面から離れるときに、その画面のインスタンスを受け取って呼ばれます。確認したい画面のルートに付けます。ログイン状態で入れる画面を決めるガードは、関連記事「Angularガードの使い方|CanActivateFnでログインリダイレクト」で扱っています。
Q. フォームの作り方を知りたいです。
A. FormGroup と dirty などの状態は、関連記事「Angularリアクティブフォームの使い方|FormGroup・バリデーション」で扱っています。ルートの設定全体は関連記事「Angularルーティング設定|provideRouter・パラメータ・404」で扱っています。
まとめ
- 未保存の離脱確認は
CanDeactivateFnをルートのcanDeactivateに登録。falseで移動を取り消す - ガードが効くのはルーターを通る移動だけ。再読み込み・タブを閉じるは
beforeunloadで止める beforeunloadの文言はブラウザ標準で変えられない。preventDefault()とreturnValueを両方書く- 「戻る」を取り消すなら
canceledNavigationResolution: 'computed'。既定では2回目の「戻る」でアプリの外へ出た - 自作ダイアログは
Promise<boolean>を返す。保存後はmarkAsPristine()
参考・出典
確認日はいずれも 2026年10月13日です。
- Angular「CanDeactivateFn」:https://angular.dev/api/router/CanDeactivateFn
- Angular「Control route access with guards」:https://angular.dev/guide/routing/route-guards
- Angular「CanDeactivate」:https://angular.dev/api/router/CanDeactivate
- Angular「RouterConfigOptions」(canceledNavigationResolution):https://angular.dev/api/router/RouterConfigOptions
- MDN「Window: beforeunload event」:https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angular CanDeactivateガード|未保存の離脱確認と戻るボタン」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。