Introduction
Angular's canDeactivate route guard prevents navigation away from a component when there are unsaved changes. To show a confirmation modal and wait for the user's response, the guard must return an Observable<boolean> or Promise<boolean> instead of a synchronous boolean. The modal service emits the user's choice (confirm or cancel), and the guard subscribes to it, allowing or blocking navigation based on the response.
The Guard Interface
1// can-deactivate.guard.ts
2import { Injectable } from '@angular/core';
3import { CanDeactivate } from '@angular/router';
4import { Observable } from 'rxjs';
5
6export interface CanComponentDeactivate {
7 canDeactivate(): Observable<boolean> | Promise<boolean> | boolean;
8}
9
10@Injectable({ providedIn: 'root' })
11export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
12 canDeactivate(component: CanComponentDeactivate): Observable<boolean> | Promise<boolean> | boolean {
13 return component.canDeactivate ? component.canDeactivate() : true;
14 }
15}
The guard delegates to the component's canDeactivate() method, which decides whether to show a modal.
The Modal Confirmation Service
1// confirm-dialog.service.ts
2import { Injectable } from '@angular/core';
3import { MatDialog } from '@angular/material/dialog';
4import { Observable } from 'rxjs';
5import { map } from 'rxjs/operators';
6import { ConfirmDialogComponent } from './confirm-dialog.component';
7
8@Injectable({ providedIn: 'root' })
9export class ConfirmDialogService {
10 constructor(private dialog: MatDialog) {}
11
12 confirm(message: string = 'You have unsaved changes. Discard them?'): Observable<boolean> {
13 const dialogRef = this.dialog.open(ConfirmDialogComponent, {
14 data: { message },
15 disableClose: true, // Prevent closing by clicking outside
16 });
17
18 return dialogRef.afterClosed().pipe(
19 map(result => result === true) // true = confirmed, anything else = cancelled
20 );
21 }
22}
afterClosed() returns an Observable that emits once when the dialog closes, then completes. The guard receives this Observable and waits for the user's response.
The Confirmation Dialog Component
1// confirm-dialog.component.ts
2import { Component, Inject } from '@angular/core';
3import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
4
5@Component({
6 selector: 'app-confirm-dialog',
7 template: `
8 <h2 mat-dialog-title>Unsaved Changes</h2>
9 <mat-dialog-content>{{ data.message }}</mat-dialog-content>
10 <mat-dialog-actions align="end">
11 <button mat-button (click)="dialogRef.close(false)">Cancel</button>
12 <button mat-raised-button color="warn" (click)="dialogRef.close(true)">Discard</button>
13 </mat-dialog-actions>
14 `,
15})
16export class ConfirmDialogComponent {
17 constructor(
18 public dialogRef: MatDialogRef<ConfirmDialogComponent>,
19 @Inject(MAT_DIALOG_DATA) public data: { message: string }
20 ) {}
21}
The Component with Unsaved Changes
1// edit-form.component.ts
2import { Component } from '@angular/core';
3import { Observable } from 'rxjs';
4import { CanComponentDeactivate } from './can-deactivate.guard';
5import { ConfirmDialogService } from './confirm-dialog.service';
6
7@Component({
8 selector: 'app-edit-form',
9 template: `
10 <form [formGroup]="form">
11 <input formControlName="name" placeholder="Name">
12 <textarea formControlName="bio" placeholder="Bio"></textarea>
13 <button (click)="save()">Save</button>
14 </form>
15 `,
16})
17export class EditFormComponent implements CanComponentDeactivate {
18 form = this.fb.group({
19 name: [''],
20 bio: [''],
21 });
22
23 private saved = false;
24
25 constructor(
26 private fb: FormBuilder,
27 private confirmDialog: ConfirmDialogService
28 ) {}
29
30 save(): void {
31 // Save logic...
32 this.saved = true;
33 }
34
35 canDeactivate(): Observable<boolean> | boolean {
36 if (this.saved || !this.form.dirty) {
37 return true; // No unsaved changes — allow navigation
38 }
39 // Show modal and wait for response
40 return this.confirmDialog.confirm('You have unsaved changes. Discard them?');
41 }
42}
Route Configuration
1// app-routing.module.ts
2import { CanDeactivateGuard } from './can-deactivate.guard';
3
4const routes: Routes = [
5 {
6 path: 'edit/:id',
7 component: EditFormComponent,
8 canDeactivate: [CanDeactivateGuard],
9 },
10];
Without Angular Material (Custom Modal)
If you are not using Material, create a simple modal with a Subject:
1@Injectable({ providedIn: 'root' })
2export class SimpleConfirmService {
3 private response$ = new Subject<boolean>();
4
5 showConfirm = false;
6 message = '';
7
8 confirm(message: string): Observable<boolean> {
9 this.message = message;
10 this.showConfirm = true;
11 return this.response$.asObservable().pipe(take(1));
12 }
13
14 respond(value: boolean): void {
15 this.showConfirm = false;
16 this.response$.next(value);
17 }
18}
1<!-- In app.component.html -->
2<div class="modal-overlay" *ngIf="confirmService.showConfirm">
3 <div class="modal">
4 <p>{{ confirmService.message }}</p>
5 <button (click)="confirmService.respond(false)">Cancel</button>
6 <button (click)="confirmService.respond(true)">Discard</button>
7 </div>
8</div>
Angular 16+ Functional Guard
Angular 16 introduced functional guards as an alternative to class-based guards:
1import { CanDeactivateFn } from '@angular/router';
2
3export const canDeactivateGuard: CanDeactivateFn<CanComponentDeactivate> = (component) => {
4 return component.canDeactivate ? component.canDeactivate() : true;
5};
6
7// In routes
8const routes: Routes = [
9 {
10 path: 'edit/:id',
11 component: EditFormComponent,
12 canDeactivate: [canDeactivateGuard],
13 },
14];
Common Pitfalls
Returning a synchronous boolean when a modal is needed: If canDeactivate() returns true or false before the modal opens, navigation proceeds immediately without waiting. Return an Observable<boolean> that completes when the user responds.
Not using take(1) or completing the Observable: The router subscribes to the Observable and waits for it to emit. If your Observable never emits (e.g., the Subject is not triggered), navigation hangs indefinitely. Use take(1) to ensure exactly one emission.
Modal appearing on every navigation: Check form.dirty or a similar flag before showing the modal. If the form has not been modified, return true immediately without opening the dialog.
Browser back button bypassing the guard: The canDeactivate guard handles in-app navigation. For browser back/refresh/close, also listen to the beforeunload event: @HostListener('window:beforeunload', ['$event']) unloadNotification(event) { if (this.form.dirty) event.returnValue = true; }.
disableClose not set on the dialog: Without disableClose: true, clicking the backdrop closes the dialog and emits undefined, which the guard interprets as false (navigation blocked). Either set disableClose or handle undefined explicitly in the map operator.
Summary
The canDeactivate guard returns Observable<boolean> to wait for the modal response
The modal service opens a dialog and returns dialogRef.afterClosed() as the Observable
The component checks form.dirty before showing the modal — skip if no changes
Register the guard in the route configuration with canDeactivate: [CanDeactivateGuard]
Add a beforeunload listener for browser back/refresh/close events
Use take(1) on custom Subject-based modals to prevent hanging navigation