Paydirt Blog

Backbone.js in Practice: Part II – Dirty Object Handling

Accidentally discarding your work on a website can be one of the most frustrating user experiences. This can be avoided with window.onbeforeunload, which will notify the user that they are navigating away from a page that has unsaved changes.

But in a single-page application the window.onbeforeunload event doesn't necessarily fire when the user navigates, because Backbone navigation doesn't reload the the window object.

We'll have to handle both kinds of navigation, and that's what we'll be doing in today's tutorial.

Part II: Preventing users from accidentally discarding their changes.

Our goal is to show a modal notification warning the user that they have unsaved changes that will be discarded if they continue, giving them a chance to save first.

To make sure the message only shows at the right time, we need to:

  1. Keep track of which object can be changed (dirtied) on the current page, if any
  2. Keep track of whether that object has been dirtied
  3. Check whether there are unsaved changes flagged, and if there are, warn the user before navigating away
  4. Specify the ways a user can navigate away without being notified

1. Keep track of which object can be dirtied in the current view

Binding to an object's change event and always marking it as 'dirty' would result in notifications for models that were changed in the background, as a side effect of some operation or from push events. We only care about changes that will be lost if the user navigates away.

To make sure we only catch changes to the object of interest, each route sets the model that can be dirtied in that view, if there is one. We store this in Paydirt.canBeDirtied. Here's an example of a route in which we want to alert the user of unsaved changes to a Client:

class Paydirt.Router extends Backbone.Router
  clientsNew: (id) =>
    Paydirt.client = new Paydirt.Models.Client()
    //  On this page, the current client can be dirtied
    Paydirt.canBeDirtied = Paydirt.client
    @view = new Paydirt.Views.Clients.New(model: Paydirt.client)

2. Keep track of whether the object has changed

This is pretty straighforward. We extend Backbone.Model with a markDirty() function, and a default dirtyMessage() function that we'll use to populate our notification message.

Backbone.Model::markDirty = ->
  // If this is the current object of interest
  if Paydirt.canBeDirtied == this
    // Then store it as the dirty object
    Paydirt.dirty = this
    // And set window.onbeforeunload
    window.onbeforeunload = =>
      return "You have unsaved changes to " + this.dirtyMessage()
    return true

// We'll override this for each model as 'this client', 'this invoice', etc.
// But as a fallback, we'll warn the user about changes to 'this page'
Backbone.Model::dirtyMessage = -> 'this page'

And then bind to the change event on the Client model.

We also override the default dirtyMessage() to produce more meaningful notifications.

class Paydirt.Models.Client extends Backbone.Model
  initialize: ->
    // Dirty state handling
    @on('change', @markDirty)

  dirtyMessage: -> 'this client'

3. Check for unsaved changes

Since all of our navigation goes through our router's navigate function, we'll override it to check for unsaved changes.

First we set up a callback that wipes our Paydirt.dirty and Paydirt.canBeDirtied attributes and then calls Backbone.Router's regular navigate() function to complete the navigation as usual.

If the object of interest has changed, we display a confirmation dialogue asking the user what they want to do, otherwise we fire the callback immediately.

In this example we use jQuery's Deferred object and our modalConfirm dialogue to display a confirmation dialogue, but you could do whatever works best for your application.

class Paydirt.Router extends Backbone.Router
  navigate: (fragment, options) =>

    answer = $.Deferred()

    answer.promise().then =>
      // Every navigate cleans the slate
      Paydirt.dirty = null
      Paydirt.canBeDirtied = null
      window.onbeforeunload = null

      super(fragment, options)

    // You can use any method to notify the user
    // and can access the dirty object's dirtyMessage()
    // to create a more meaningful message
    if Paydirt.dirty
        message: "You haven't saved your changes to " + Paydirt.dirty.dirtyMessage(),
        cancel: "No, don't discard changes", 
        confirm : "Yes, discard changes"
      }, answer)
      return answer.promise()

When the user clicks ok in the modal confirmation, we call resolve() on the deferred object, and the actual navigation is performed.

If the user clicked a link that isn't handled by our router, or if they closed the browser tab/window, none of this will run and we'll fall back on window.onbeforeunload.

4. Specify the ways a user can navigate away without being notified

We're almost done, but lastly we need to white-list particular methods for navigating away. Otherwise, the user will be warned about unsaved changes, even if they navigate away by saving!

To do that we extend Backbone.Model with a markClean() function, and call it on the object in view functions that don't require a warning. In this example, clicking save or cancel:

Backbone.Model::markClean = ->
  if Paydirt.dirty == this
    Paydirt.dirty = null
    window.onbeforeunload = null

class Paydirt.Views.Clients.New extends Paydirt.View
    'submit form.client_form': 'save'
    'click a.cancel': 'cancel'

  save: (e) =>
    e.preventDefault() {},
      success: (model, response) =>
        // The model was saved, so mark it as clean

  cancel: (e) =>
    // Changes to the model were discarded intentionally, so mark it as clean

There you have it - pretty simple dirty state handling for your Backbone.js app.

Want to track your time, send invoices, and get paid? You should take Paydirt for a spin.

Designed from the ground up for smart freelancers and savvy teams.

   or    Find out more