Prevent Navigation from Angular Forms with Uncommitted Data

The code for this article is available on my GitHub:
JeffreyBane/ALF-My-Dirty-Form-App (github.com)

Problem: My users are stupid and keep navigating away from a form with changed or dirty data or closing the browser altogether then complain because none of their changes are showing up.

Your users are not stupid, they’re just busy with jobs that aren’t writing software. We all face this common problem with end users. They either:

  • navigate away from a form with uncommitted / dirty data.
  • close the browser or tab when a form has uncommitted / dirty data.

Typically, this is followed by blaming the app for not saving their changes. While we have some options here, such as forms that save every so often,
today we’ll look at a pretty typical solution – a simple confirmation prior to navigation or closing the browser / tab. There are several approaches to this, but the following is how I’ve implemented this several times and I’ve found it to be pretty durable. If you have a different approach, feel free to share it or improve upon this one.

We’ll need to hit this from two angles, using an Angular route guard for in app navigation and then hooking to a DOM event in case the user tries to close a tab or the entire window.

NOTE – If you’re using lazy loading with your modules (as we do where I work), there’s some changes you’ll need to make which I’ll go over at the very end of the article.

So let’s get started – we’ll create 2 components in our app, one that will utilize the dirty form logic, the other will be a separate route so we can see the navigation behavior in action:

>> ng g c dirty-form
>> ng g c other-route

Now we’ll need to set up a route to each component:

(app-routing.module.ts)

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DirtyFormComponent } from './dirty-form/dirty-form.component';
import { OtherRouteComponent } from './other-route/other-route.component';

const routes: Routes = [
  {
    path: 'DirtyForm',
    component: DirtyFormComponent,
  },
  {
      path: 'OtherRoute',
      component: OtherRouteComponent      
  }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Ok, super simple, we have an app with 2 components and the ability to route between them. Let’s do some modifications to our dirty-form.component files.

Dirty Form HTML (dirty-form.component.html)

<p>Form goes here.</p>

Amazing levels of realism I know, but in actuality we don’t need a form to demonstrate what we’re doing here, just a placeholder.


Earlier I stated we’d need to attack this from two angles, and for both we’ll need an Angular route guard. Route guards, if you’ve never used one are interfaces provided by Angular that allow us to control access to our routes. There are several types which you will read up on at the end of the article. For our purpose, thankfully Angular provides just the type of route guard we need called CanDeativate. Let’s create that very thing in a new file:

Route Guard (dirty-form-guard.ts)

import { CanDeactivate } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

export interface ComponentCanDeactivate {
  canDeactivate: () => boolean | Observable<boolean>;
}

@Injectable()
export class DirtyFormGuard implements CanDeactivate<ComponentCanDeactivate> {
  canDeactivate(component: ComponentCanDeactivate): boolean | Observable<boolean> {
    return component.canDeactivate() ? true :
      confirm('WARNING: You have unsaved changes. Press Cancel to go back and save these changes, or OK to lose these changes.');
  }
}

End result of this – we’ll be able to use a boolean (say formUnchanged or something of the like) to determine if a user can navigate away from a page or close the browser window. This is powerful stuff and makes it very easy to implement on every form in our app. Total win.

So how do we use this newfound power? First, let’s make sure we provide it in our app module:

App Module (app.module.ts)

....
  providers: [DirtyFormGuard],
...

And now in our dirty form component file, let’s wire up the guard:

Dirty Form Component (dirty-form.component.ts)


import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ComponentCanDeactivate } from '../dirty-form-guard';

@Component({
  selector: 'app-dirty-form',
  templateUrl: './dirty-form.component.html',
  styleUrls: ['./dirty-form.component.scss']
})
export class DirtyFormComponent implements OnInit, ComponentCanDeactivate {

  formUnchanged = false;

  public canDeactivate(): Observable<boolean> | boolean {
    
    return this.formUnchanged;

  }


  constructor() { }

  ngOnInit(): void {
  }

}

So 2 things in our DirtyFormComponent, notice we implement the interface from our route guard, ComponentCanDeactivate:

export class DirtyFormComponent implements OnInit, ComponentCanDeactivate {

This is good practice and ensures that we provide the guts for (or an implementation of) the required method canDeactivate():


  public canDeactivate(): Observable<boolean> | boolean {
    
    return this.formUnchanged;

  }

Now in this case we are doing something very simple – we have a variable called formUnchanged (set to false) as a placeholder to indicate that a user has made a change to the form on the page.

This is in the interest of sanity. I’d prefer a series of smaller articles that address a single need rather than something that’s 1700 pages long. The reason being is HOW to tell if a user has truly made a change to a form is an entire discussion in itself (which everyone seems to have their own approach to)
and will be covered in its own article which I will link to here: Simple Change Detection on Angular Forms – Angular Love Fest


Give that a good read when you have some time, but for now let’s simply concentrate on the topic at hand of how to prevent navigation when the form has changed and for that purpose – our little boolean will do just fine.

So our final step in preventing in app routing is to assign this route guard to whatever components we care to. In this case, we’ll attach it to our dirty form component in our app routing file:


................
  {
    path: 'DirtyForm',
    component: DirtyFormComponent,
    canDeactivate: [DirtyFormGuard]
  }
...............

I mentioned earlier that we can use this logic in every form in our app and you can see how easy it is to use this guard on whatever routes we want. Let’s see what happens now when we start our app and try to navigate away from our dirty form component:

Fabulous! You can see anytime we click on the “Dirty Form” route then try to navigate to “Other Route”, our confirmation box fires and forces us to hit ok to proceed (or cancel to stay). Also note if we set our formUnchanged variable to true in the dirty form component, this simulates a user having made no changes to the form and navigation proceeds without any prompting.

So that’s the first part of the battle – note that you can still close the tab or close the browser entirely and the prompt doesn’t occur, leaving a way for our users to lose uncommitted data. Closing the page / tab is a bit tricker (but not super difficult) to handle and for that we’ll have to hook into the DOM of the web browser. Fortunately, angular makes this very straightforward.

Begins yelling at cloud
If you don’t know what the DOM (document object model) is, you’re likely to have not been doing much web programming in the 90s. Back when I had to walk uphill to school both ways and the only game on my phone was Snake, we had to deal with DOM of various web browsers…
End yelling at cloud moment

tl;dr – there’s objects and events in your web browser itself that, even though we are very much shielded from them these days thanks to all the wonderful JavaScript frameworks, we still need to access once in a while. And I said Angular makes that very straightforward and indeed they do with a decorator called @HostListener.

From Angualr.io: (@HostListener) Decorator that declares a DOM event to listen for and provides a handler method to run when that event occurs.

EXACTLY what we need and the DOM event in question that we want to listen for is ‘window:beforeunload’:

From Mozilla: The beforeunload event is fired when the window, the document and its resources are about to be unloaded.

So let’s listen for that DOM event and provide a method to run when that event occurs. Make this simple change in dirty-form.component.ts by adding the @HostListener decorator before our canDeactivate() method:

Dirty Form Component (dirty-form.component.ts)

  @HostListener('window:beforeunload')
  public canDeactivate(): Observable<boolean> | boolean {
    
    return this.formUnchanged;

  }

Now, back in our app, in addition to getting our confirmation prompt when we try to navigate away from the dirty form page, if we try to close the tab or the browser itself:

Now we’ve got our users right where we want them! (not really but count the small victories) If we determine there is uncommitted data, we can now both prevent navigating away from the page and prevent closing the tab or browser without confirmation.

NOTE – You’ll note the message is different when they try to close the browser. Why is that? The short answer – most modern browsers don’t support custom messages in the beforeunload popup. I don’t find this very troubling as the message is pretty clear. You can also copy this message and use it in your canDeativate method if you need them to be consistent.

IF YOU’RE USING LAZY LOADED MODULES
We use lazy loaded modules where I work and I had real problems making this work at first, it just wouldn’t work, or I’d get null component error.
Let me save you the hair pulling on this one – it’s mentioned here in this issue at the Angular github:
https://github.com/angular/angular/issues/16868 (About 1/3 of the way down – picninim commented on Sep 20, 2017 – explains it).


Luckily, it’s not a difficult change, but if you’re using lazy loaded modules you need to move the canDeactive from the app-routing.module.ts to the lazy loaded modules’ routing file, in our example, if we used lazy loaded modules, that would be in the file dirty-form-routing.module.ts:

Dirty Form Routing Module (dirty-form-routing.module.ts)

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { DirtyFormGuard } from '../dirty-form-guard';
import { DirtyFormComponent } from './dirty-form.component';

export const CHILD_ROUTES: Routes = [{

  path: '',
  component: DirtyFormComponent,
  canDeactivate: [DirtyFormGuard]
}];

 
@NgModule({
  imports: [
    RouterModule.forChild(CHILD_ROUTES)

  ],

  declarations: [],
  exports: [
    RouterModule
  ]

})

export class DirtyFormRoutingModule { }

And you’d follow the above pattern for each lazy loaded module.

You go read now:

Angular route guards
https://codeburst.io/understanding-angular-guards-347b452e1892

Host listener
https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event

One thought on “Prevent Navigation from Angular Forms with Uncommitted Data”

  1. This is great, I love how you cover preventing both navigating away and also closin the browser. I’m using this on all my apps from now on thank you.

Leave a Reply

Your email address will not be published. Required fields are marked *