This page is part of a static HTML representation of TriTarget.org at https://tritarget.org

Page Unload Management

Sukima 15th January 2024 at 12:40pm

Something that often gets overlooked in web based apps is how to manage preventing the user from leaving the app before it has had a chance to finish saving the dirty data. This is complicated by the fact that there are two entry points to handle this depending on what the user does and that its use is tightly coupled to the form(s) data handling a dirty state.

Even writing that sentence above has me feeling like this topic is complicated regardless of the simplicity we see in the many stack overflow conversations. When I’m faced with this in my own project I will often push the specifics into its own abstraction. I call this an UnloadManager because I lack any ounce of creative naming skills. What this class does is register an unload event listener for browser use, provide a hook the app code can call to present the user an in-app based confirmation, and offer an API to toggle the dirty state of the app.

With that kind of interface the app can tap into the unload manager to perform the complex aspects of managing the different ways the app/browser can interact with the user about asking them “are you sure you want to abandon dirty changes?”

Thus there are three aspects to this interface:

  1. The app’s dirty state ― for knowing when it is appropriate to interrupt the user to confirm their intention to abandon changes
  2. The browser’s beforeunload event ― for when the user performs a page refresh or moves away from the current page/app
  3. The app’s confirmation system ― for when the user performs and action that triggers the app’s internal routing system. For example: closing a dialog and navigating between app sections (without refreshing the page itself)

Dirty state

Most of the frameworks I’ve seen can provide for a resource that your app code hooks into. In Ember this is called a service In VanillaJS this could be a class instance in the main app module. A place to store the state of if the app is dirty (what ever that means) and that state can be used to make the decision to ask the user for confirmation or not when they navigate away from their current focus. Thus something very simple will do for now, a boolean field and some fancy named methods to make the intention well known.

export class UnloadManager {
  isDirty = false;

  dirty(): void {
    this.isDirty = true;
  }

  reset(): void {
    this.isDirty = false;
  }
}

Handling beforeunload

With the state managed we can use that to turn on and off the beforeunload handler. That way it is out of the way normally but will request the browser to confirm before leaving the page.

The beforeunload is a bit of magic because it doesn't work the same as normal event handlers and it is different for every browser. I found the following pattern works well in most browsers.

export class UnloadManager {
  // …

  constructor(win = window) {
    win.addEventListener(
      'beforeunload',
      this.handleUnload,
      this.cleanup,
    );
  }

  dispose(): void {
    this.cleanup.abort();
  }

  private cleanup = new AbortController();
  private handleUnload =
    (event: BeforeUnloadEvent): string | undefined => {
      if (!this.isDirty) return;
      event.preventDefault();
      event.returnValue = '';
      return 'unsaved changes';
    };
}

Might be worth describing some of the things going on here. The handleUnload is an arrow function so the this context is not lost when we attach the event. In it we check the isDirty value and either do nothing (the user never dirtied the app) or it will prevent the default (I think this is for Firefox), set the returnValue to an empty string (this is for Safari), and return a non-empty string message (this is for Chrome). When these things happen in a beforeunload event the browser will present the user its own type of dialog confirmation.

When the class is constructed we attach the event and also provide a way to detach the event in cases where we may need to instantiate and dispose of multiple unload managers like we would during testing. I like to think that when I tickle global state it will lead to memory leaks if I am not a nice citizen and offer a way to clean up after myself.

Using AbortController for event detachment management

A clean way to handle event attachment is to use an AbortController because its interface is designed to abort whatever its signal is attached to. In older versions of JavaScript we had to keep a reference to our handler function to call removeEventListener or design some kind of closure system. But with the AbortController we can do this in a more abstract declarative way.

This is different then the use of once because that requires the event to be triggered in order for it to be cleaned up (detached) We also don’t want prematurely detach till we request it from dispose().

In this case instead of a reference to the bound function and repeating the calling arity we can pass it a signal that will auto-detach when the controller is aborted. I found it far cleaner.

OK ✗

let handler1 = () => { … };
let handler2 = () => { … };
let handler3 = () => { … };

document.addEventListener('…', handler1);
document.addEventListener('…', handler2);
document.addEventListener('…', handler3);

function cleanup() {
  document.removeEventListener('…', handler1);
  document.removeEventListener('…', handler2);
  document.removeEventListener('…', handler3);
}

Better ✓

let controller = new AbortController();

document.addEventListener('…', () => { … }, controller);
document.addEventListener('…', () => { … }, controller);
document.addEventListener('…', () => { … }, controller);

let cleanup = () => controller.abort();

User confirmations

The above will prevent the user from closing the browser or leaving the site but there is a trade off here. First, the UI is out of our control, the browsers offer a generic message and that is all. Second, it only comes into play if the navigation changes/updates the browsers URL―in essence a full new page to unload and re-load.

In the case of in-app navigation we would want a different kind of confirmation. For example perhaps we may want a modal dialog box to display. We can accomplish this by offering a method that can show a confirmation or not based on the isDirty state.

There is a lot to doing this, especially since modal dialog UIs are difficult to do well and are asynchronous by nature. I’ve gone into detail about modal dialog boxes and the use of a modal manager pattern. If we use this pattern it offers a generic and flexible way to accomplish this. The basic interface is an open() method that returns Promise<ModalResult> where the ModalResult is a POJO with a reason and a value. The reason is one of: confirmed, cancelled, or rejected. That will allow us to compose our dialog management without unload management.

interface ModalResult {
  reason: 'confirmed' | 'cancelled' | 'rejected';
  value?: unknown;
}

interface ModalManager {
  open(): Promise<ModalResult>;
}


export class UnloadManager {
  // …

  async confirmation(modal: ModalManager): Promise<ModalResult> {
    if (!this.isDirty) return { reason: 'confirmed' };
    let result = await modal.open();
    if (result.reason === 'confirmed') this.reset();
    return result;
  }
}

To see this in action try the full implementation for yourself: DEMO

Discuss this article