Yes Blocking page navigation in Angular with scale

Blocking page navigation is a common scenario, usually applied when leaving the page will cause the loss of data or state. For example, when the page contains user input (form) that will be lost once leaving the page.

The common implementation is to show a confirm box, warning the user about the implication and letting him/her choose if to proceed (lose the data).

When are we leaving the page?

In DOM events, leaving the page occurs when:

  • The browser or tab is closed.
  • Navigating to a different URL

In all of the above, the browser will unload the page.

In angular (or any other Single Page Application) leaving the page occurs when the angular router navigates to a different page registered in the router configuration.

We need to handle both of the scenarios.

When got a task to provide such functionality I started searching for an existing solution. One that can scale as the application code base grows.

I came across this excellent article with detailed examples, thanks Brachi Packter!

The solution didn’t feet my needs, mostly because pages (components) had more to do then actually required. They had to extends a base class as well as register for events internally.

I needed a cleaner solution, where the responsibility of the page itself is minimal. No inheritance, no event handling and no UI (popup) interaction.

Scaling with responsibility

A page (component) is responsible for the logic, deciding if we can navigate away from a page at each point in time. We don’t care why and how, we only care if YES or NO.

Some pages might implement logic and some does not even have any state, not caring for this at all.

We achieve scaling by making sure that the pages only handle their internal state without the need to register to events or performing any operation other than returning YES or NO for page navigation when asked for.

Considering that leaving the page is done in 2 ways, this is important. Once the application grows it becomes boilerplate hell repeating code over and over which raise the risk for bugs.

Every component that wants to be able to block navigation should implement the CanDeactiveComponent interface:

export interface CanDeactivateComponent {
canDeactivate(): boolean;

We need to provide solutions in 2 fronts, web browser DOM events and angular router hooks but from the perspective of a page it irrelevant…

DOM Events

In a web browser, blocking page navigation is done by registering to the beforeunload event on the window object. This event is fired when a page unload.

The simple, straight forward solution would be:

For every component we want to block navigation we need to:

  • Use HostListener to register the event
  • Apply the logic (check we have data we might loose)
  • If so, pop up a confirm box with proper text.
  • If user opt to stay in the page, notify the event to cancel the navigation.

A lot of steps, putting more responsibility on the pages (components) then we want. We want pages to have a simple function, returning YES or NO based on logic without handling event data and UI confirmation.

To achieve our goal we need to handle the event in a single location, that location needs to have access to the components (instances).

With angular this actually very easy.

By plugin in to the RouterOutlet using a directive we can register to the activate and deactivate streams and save references to the component instances.

Additionally, since RouterOutlet is an element we can use HostListener to listen to beforeunload events.

CanDeactivateNotification is a service that will show a confirmation popup and return the user selection. (YES or NO)

We use the same selector defined in the RouterOutlet component itself, router-outlet. Because it is a directive there is no conflict. The nice thing here is that we now have reference to all route levels including auxilary routes.

It is also a seamless solution, we just wrote code for the directive itself and registering it in the rootNgMoudule. From here there is nothing else we need to do!

From here on, every component that implements CanDeactivateComponent will participate in the block navigation process.

Note that this implementation will also be used for angular router hooks.

That was easy, now let’s take care of the angular router.

Angular Router

Angular router allow blocking of navigation to/from a route using Route Guards, the terminology is Activating or Deactivating a route.

If a route has a CanDeactivate guard defined the router will invoke it checking how to proceed, let’s implement our CanDeactivate guard:

Quite similar to the directive we built. This time the component is provided by the router.

Note that we need to explicitly define a guard for each route/component pair we want to apply the CanDeactivate on. This is extra work, unlike the DOM Events solution that automatically detect it based on the page intent.

More on this below…

The last thing left is to implement our notification service, which pops up a confirmation box with a proper message:

This is a very simple implementation, you might want to add configuration and metadata allowing customisation of the message (i18n, tags, etc…)

Just make sure you are using a blocking (synchronous) implementation because both the web browser events and the router does not support an async API.

Now, we only need to:

  • Implement CanDeactivateComponent in pages (components) the we want to optionally block.
  • Register the CanDeactivate guard we built in all relevant routes.
As previously mentioned, the implementation for CanDeactivateComponent is done once for both DOM Events and the angular router guards.

Registering route guards

We mentioned earlier that we need to explicitly define a guard for each route/component pair we want to apply the CanDeactivate on.

This forces us to define which page play the can deactivate game in 2 places:

1) The pages itself (by implementing CanDeactivateComponent)

2) In route definitions, at the canDeactivate property.

Our mechanism already work with (1) when the DOM Event is fired, so reusing it for the angular router guard is what we want.

We don’t need (2), it is there only for the angular router.

It would be great if we can avoid the 2nd step. Most probably this will be done by grouping a list of routes under a common parent (child routes) and having a new guard invoked at the parent, CanDeactivateChild guard.

Currently, the router does not provide an API that we can use to aggregate routes under one guard.

This API does exist for route activation through the CanActivateChild guard.

There is a pending issue request for the angular team to support the CanDeactivateChild guard as well (see angular/angular#11836).
Please drop a comment there, asking the team to implement the feature.

Final thoughts

I’m very happy with the final outcome.

I was able to listen to beforeunload DOM Events without changing existing code and consolidate both DOM Events and router hooks into the same stream.

Once the angular router support the CanDeactivateChild hook this solution will be seamless, scaling to any app size.

The only thing required to optionally block navigation from a page is to implement CanDeactivateComponent

Please share your thoughts in the comments below, if you find issues or ways to improve please comment.

Thanks, Shlomi