Prevent navigating when using async CanDeactivate in Angular
I was working on an Angular project which contained pages with a form. When a user tries to navigate away after modifying a form, a modal would appear, warning them about unsaved changes. They could either confirm to proceed or cancel to stay on the page. This confirmation modal was displayed asynchronously using a Bootstrap modal.
We used Angular's CanDeactivate guard, which called the canDeativate() of the page component. This guard cancels the navigation. The solution we implemented worked pretty well, except there was an issue.
The issue
Turns out, a user could still leave the page by quickly clicking the browser's back button several times in a row. Since this wasn't the intended behavior, I wanted to find a way to prevent this.
Let me show you the solution I created.
Route config
The route configuration for the page is pretty straight forward. The route included a CanDeactivate guard, which invoked a method on the page component.
export const routes: Routes = [
// ...
{
path: 'store/:id',
component: StoreDetailsPageComponent,
canDeactivate: [
(component: StoreDetailsPageComponent) => component.canDeactivate()
]
},
// ...
];
CanDeactiveCheckFn type
Create a type definition for a 'check' function that returns a boolean (asynchronously).
type CanDeactiveCheckFn = () => Observable<boolean> | Promise<boolean> | boolean;
NavigationService
Create a singleton service, and just like the page component, this service has a canDeactivate(...) method. This method will be called later from the page component. The method of this service has a parameter which is a function of type CanDeactivateCheckFn.
This method ensures the check runs only once at a time by verifying whether it has already been triggered and is still in progress. It returns an Observable that emits a boolean, indicating whether the page can be deactivated.
import { Injectable } from '@angular/core';
import { finalize, from, Observable, of, take } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class NavigationService {
private inProgress = false;
canDeactivate(checkFn: CanDeactiveCheckFn): Observable<boolean> {
// 1) Prevent navigating back with browser's back button.
history.pushState(null, '', location.href);
// 2) Check if this logic is currently in progress.
if (this.inProgress) {
return of(false);
}
// 3) Set 'in progress' flag.
this.inProgress = true;
// 4) Call the given 'check' function and convert the result to an Observable<boolean>.
// Reset the 'in progress' flag and return the boolean result.
const result = new Subject<boolean>();
this.getCheckFnObservable(checkFn)
.pipe(
take(1),
finalize(() => {
this.inProgress = false;
})
)
.subscribe({
next: (data) => {
this.result.next(data);
}
});
return result.asObservable();
}
// This method will return an Observable with a boolean 'check' function result.
private getCheckFnObservable(checkFn: CanDeactiveCheckFn): Observable<boolean> {
let result$: Observable<boolean>;
try {
const result = checkFn();
if (result instanceof Observable) {
result$ = result;
} else if (result instanceof Promise) {
result$ = from(result);
} else {
result$ = of(result);
}
} catch (error) {
result$ = of(false);
}
return result$;
}
}
The page
Below an example of a page component with a form. This component has the CanDeactivate() method and uses the service.
import { Component, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DiscardChangesService } from '../../../services/discard-changes/discard-changes.service';
import { NavigationService } from '../../../services/navigation/navigation.service';
@Component({
selector: 'app-can-deactivate-page',
templateUrl: './can-deactivate-page.component.html',
styleUrl: './can-deactivate-page.component.scss',
imports: [
FormsModule,
ReactiveFormsModule
]
})
export class StoreDetailsPageComponent {
readonly form = new FormGroup({
// Form controls
});
private readonly discardChangesService = inject(DiscardChangesService);
private readonly navigationService = inject(NavigationService); // <-- the singleton service
canDeactivate(): Observable<boolean> {
// If form is not dirty, we can navigate away from the page.
if (!this.form.dirty) {
return of(true);
}
// The definition of the 'check' function.
// In this example, we show a modal in which the user can choose to discard changes.
// The method returns a Promise<boolean>.
const checkFn = async () => {
return await this.discardChangesService.showDiscardChangesModal();
};
// Let our service perform the 'can deactivate' logic and return the result.
return this.navigationService.canDeactivate(checkFn);
}
}
The popstate event
When the user presses the browser's back button, an 'popstate' event is fired and the page component's CanDeactivate() method is called.
Unfortunately, there is no way in browsers to suppress the popstate event or stop the back button from triggering it. Therefore we use a manipulation hack so it appears that the browser is not navigating back, by calling history.pushState() with the URL of the current page.
Conclusion
While Angular's CanDeactivate guard works well for showing an async confirmation modal, it fails if the browser's back button is clicked repeatedly in quick succession. To solve this, I built a service that centralizes the canDeactivate logic and prevents these navigation issues.
The service is reusable across different pages and can accommodate any custom 'can deactivate' check logic.