Breakdown of the Ampersand.js implementation on TodoMVC.com

TodoMVC is a neat project with a simple idea: to build the same application with a whole slew of different frameworks so we can compare how they solve the same problems.

The project's maintainers asked me to contribute an example in Ampersand.js, so that's what we did.

There are a few aspects of the implementation that I thought were worth writing up.

First, some highlights

  1. filesize: Total filesize of all the JS assets required for the app only 24kb(minified/gzipped) which, for comparison, is smaller than jQuery by itself. By comparison the Ember.js version is 165kb and that's without including compiled templates.

  2. super efficient DOM updating: Nothing is re-rendered to the DOM just because the underlying model changed. Only state changes that result in a different outcome touch the DOM at all, and when we do need to update the DOM because of a state change, it's done using specific DOM methods such as .setAttribute, .innerText, .classList.add, etc. and generally not with .innerHTML. This matters because innerHTML is slower, because it requires the browser to parse HTML. The point is that after the initial render, it does the absolute minimum number of DOM updates required, and does them as efficiently as possible.

  3. good code hygiene: Maintainable, readable code. All state stored in models, zero state stored in the DOM. Fully valid HTML (I'm looking at you, Angular). Call me old school, but behavior is in JS, styles is in CSS, and structure is in HTML.

  4. fully template language agnostic: We're using jade here, but it really doesn't matter because the bindings are all handled outside of the templating, as we'll see later. You could easily use the template language of your choice, or even plain HTML strings.

Ok, now let's get into some more of the details.

Persisting todos to localStorage

The TodoMVC project's app spec specifies:

Your app should dynamically persist the todos to localStorage. If the framework has capabilities for persisting data (i.e. Backbone.sync), use that, otherwise use vanilla localStorage. If possible, use the keys id, title, completed for each item. Make sure to use this format for the localStorage name: todos-[framework]. Editing mode should not be persisted.

This is ridiculously easy in Ampersand. This could be done this as a mixin, so we could use the "backbone-esque" .save() methods on the models. But given how straightforward this use case is, it's simpler to just do it directly. We simply create two methods.

One to write the data to localStorage:

writeToLocalStorage: function () {
  localStorage[STORAGE_KEY] = JSON.stringify(this);
}

One to retrieve it:

readFromLocalStorage: function () {
  var existingData = localStorage[STORAGE_KEY];
  if (existingData) {
    this.set(JSON.parse(existingData));
  }
}

You'll notice we're just passing this to JSON.stringify. This works because ampersand collection has a toJSON() method and the spec for the browser's built-in JSON interface states that it will look for and call a toJSON method on the object passed in, if present. So rather than doing JSON.stringify(this.toJSON()), we can just do JSON.stringify(this). Ampersand collection's toJSON is simply an alias to serialize which loops through the models it contains and calls each of their serialize methods and returns them all as a serializable array.

So far we've just created the methods and not actually used them, so how do we wire that up?

Well, given how simple the app requirements are in this case: "save everything when stuff changes," we can just have the collection watch itself and persist when it changes. Then our models don't even have to know or care how they get persisted, the collection will watch itself, and whenever we add/remove or change something it'll re-save itself. This keeps our logic nicely encapsulated into the collection whose responsibility it is to deal with models. Makes sense, right?

Turns out, that's quite easy too. Inside our collection's initialize method, we'll do as follows. See line comments below:

initialize: function () {
  // Attempt to read from localStorage right away
  // this also adds them to the collection
  this.readFromLocalStorage();
  
  // We put a slight debounce on this since it could possibly
  // be called in rapid succession. We're using a small npm package
  // called 'debounce' for this: 
  // https://www.npmjs.org/package/debounce
  this.writeToLocalStorage = debounce(this.writeToLocalStorage, 100);
  
  // We listen for changes to the collection
  // and persist on change
  this.on('all', this.writeToLocalStorage, this);
}

Syncing between multiple open tabs

Even though it's not specified in the spec, we went ahead and handled the case where you've got the app open in multiple tabs in the same browser. In most of the other implementations, this case isn't covered, but it feels like it should be. Turns out, this is quite simple as well.

We simply add the following line to our initialize method in our collection, which listens for storage events from the window:

window.addEventListener('storage', this.handleStorageEvent.bind(this));

The corresponding handler inside our collection looks like this:

handleStorageEvent: function (event) {
  if (event.key === STORAGE_KEY) {
    this.readFromLocalStorage();
  }
}

The event argument passed to our storage event handler will includes a key property which we can use to determine which localStorage value changed. These storage events don't fire in the tab that caused them, and they only fire in other tabs if the data is actually different. This seems perfect for our case. So we simply check to see if the change was to the key we're storing to, run readFromLocalStorage, and we're good.

That's it! Here's the final collection code.

note: It's worth noting that the app spec for TodoMVC is a bit contrived (understandably). If you're going to use localStorage in a real app you should beware that it is shared by all open tabs of your app, as well as the fact that your data schema may change in a future version. To address these issues, consider namespacing your localStorage keys with a version number to avoid conflicts. While all these problems can be solved, in most production cases you probably shouldn't treat localStorage as anything other than an somewhat untrustworthy cache. If it uses it to store something important and the user clears their browser data, it's all gone. Also, you can't always trust that you'll get valid JSON back, so a try/catch would probably be wise as well.

Session properties for editing state

If you paid close attention, you noticed the TodoMVC application spec also says we shouldn't persist the editing state of a todo. This refers to the fact that you can double-click a task to put it into edit mode.

One thing that's a bit unique in Ampersand is its use of what we call "session properties" to store things like the editing state.

If you look at the other examples, both Ember and Backbone only reference the "editing" state in the view code or the view controller; there's no reference to it in the models. Compare that to our todo model:

var State = require('ampersand-state');


module.exports = State.extend({
  // These properties get persisted to localStorage
  // because they'll be included when serializing
  props: {
    title: {
      type: 'string',
      default: ''
    },
    completed: {
      type: 'boolean',
      default: false
    }
  },
  
  // session properties are *identical* to `props`
  // *except* that they're not included when serializing.
  session: {
    // here we declare that editing state, just like
    // the properties above.
    editing: {
      type: 'boolean',
      default: false
    }
  },
  
  // This is just a convenience method that just
  // gives our view a simple method to call when 
  // it wants to trash a todo.
  destroy: function () {
    if (this.collection) {
      this.collection.remove(this);
    }
  }
});

You might be thinking, WHAT!? You're storing view state in the models?!

Yes. Well... sort of.

If you think about it, is it really view state? I'd argue it's "application state," or really "session state" that's very clearly tied to that particular model instance.

Conceptually, at least to me, it's clear that it's actually a state of the model. The view is not in "editing" mode; the model is.

How the view or the rest of the app deals with that information is irrelevant. The fact is, when a user edits a todo, they have put that particular todo into an editing state. That has nothing to do with a particular view of that model.

This distinction becomes even more apparent if your app needs to do something else based on that state information, such as disabling application-wide keyboard shortcuts, or applying a class to the todo-list container element when it's in edit mode.

Even if you disagree with that, what about readability? Let's say you're working with a team on this app, where can they go to see all the state we're storing related to a single todo?

In the Backbone.js example the model code reads like this:

app.Todo = Backbone.Model.extend({
  // Default attributes for the todo
  // and ensure that each todo created has `title` and `completed` keys.
  defaults: {
    title: ',
    completed: false
  },
  
  // Toggle the `completed` state of this todo item.
  toggle: function () {
    this.save({
      completed: !this.get('completed')
    });
  }
});

and in Ember:

Todos.Todo = DS.Model.extend({
  title: DS.attr('string'),
  isCompleted: DS.attr('boolean')
});

Neither of these give any indication that we also care about whether a model is in editing mode or not. We'd have to dig into the view to see that. In an app this simple, it's not a big deal. In a big app this kind of thing gets problematic very quickly.

It feels so much clearer to see all the types of state related to that model in a single place.

Using a subcollection to get filtered views of the todos

The spec says we should have 3 different view modes for our todos:

  1. All todos
  2. Remaining todos
  3. Completed todos

There are a few different ways we could go about this. We've got our trusty ampersand-collection-view which will take a collection and render a view for each item in the collection. It also takes care of adding and removing items if the collection changes, as well as cleaning up event handlers if the parent view is destroyed.

That collection view is included in ampersand-view and is exposed as a simple method: renderCollection.

One way to accomplish what's being asked in the spec would be to create three different collections and shuffle todos around between collections based on their completed state. But that feels a bit weird. Because we really only have one item type. We could also have a single base collection and request a new filtered list of todos from that collection each time any of them changes, which is how the Backbone.js implementation does it. But that would mean that it's no longer just a rendered collection. Instead we'd have to re-render a view for each todo in the matching set, which doesn't feel very clean or efficient.

It seems cleaner/easier to just have a single todos collection and then render a "filtered view," if you will. Ideally, we'd just be able to set a mode of that filtered view and have it add/remove as necessary.

So we want something that behaves like a normal collection, but which is really just a subset of that collection.

Then we could still just call renderCollection once, using that subcollection.

Then if we change the filtering rules of the subcollection things would Just Work™. In ampersand we've got just such a thing in ampersand-subcollection.

If you give it collection to use as a base and a set of rules like filters, a max length, or its own sorting order, then it pretends to be a "real" collection. It has a models array of its current models, a length property, its own comparator, and it will fire events like add/remove/change/sort as the underlying data in the base collection changes, but it will fire those events based on its own defined filters and rules.

So, let's use that. In this case we just need a single subcollection, so we'll just create it and attach it to the collection as part of its initialize method:

var Collection = require('ampersand-collection');
var SubCollection = require('ampersand-subcollection');
var Todo = require('./todo');


module.exports = Collection.extend({
  model: Todo,
  initialize: function () {
    ...
    // This is what we'll actually render
    // it's a subcollection of the whole todo collection
    // that we'll add/remove filters to accordingly.
    this.subset = new SubCollection(this);
    ...
  },
  ...
}

Now, rather than just rendering our collection, in our main view we'll render the subcollection instead:

this.renderCollection(app.me.todos.subset, TodoView, this.queryByHook('todo-container'));

We'll talk about model structure in just a minute, but for now let's just realize that app.model.todos is our todos collection and app.model.todos.subset was the subcollection we just created above.

The TodoView is the constructor (a.k.a. view class) for the view we want to use to render the items in the collection and this.queryByHook('todo-container') will return the DOM element we want to render these into. If you're curious about queryByHook, see this explanation of why we use data-hook.

So, now we can just re-configure that subcollection and it will fire add/remove events for changes based on those filters and our collection renderer will update accordingly.

There are three valid states for the view mode we're in. It can be "active", "completed", or "all". So now we create a simple helper method on the collection that configures it based on the mode:

setMode: function (mode) {
  if (mode === 'all') {
    this.subset.clearFilters();
  } else {
    this.subset.configure({
      where: {
        completed: mode === 'completed'
      }
    }, true);
  }
}

So where does that mode come from? Let's look at our model structure.

Modeling state

In Ampersand a common pattern is to create a me model to represent state for the user of the app. If the user is logged in and has a username or other attributes, we'd store them as props on the me model. In this app, there's no persisted me properties, but we do still have a user of the app we want to model and that user has a set of todos that belong to them. So we'll create that as a collection property on the me object like so:

var State = require('ampersand-state');
var Todos = require('./todos');


module.exports = State.extend({
  ...
  collections: {
    todos: Todos
  },
  ...  
});

Things that otherwise represent "session state" or other cached data related to the user can be attached to the me model as session properties as we described above.

Something like the mode we described above fits into that category.

Ideally, we should be able to simply change the mode on the me model and everything else should just happen.

And, since we're using ampersand-state, we can change the entire mode of the app with a simple assignment, as follows:

app.me.mode = 'all';

Go ahead and open a console on the app page and try setting it to various things. Note that it will only let you set it to a valid value. If you try doing: app.me.mode = 'garbage' you'll get this error:

type error

This type of defensive programming is hugely helpful for catching errors in other parts of your app.

This works because we've defined mode as a session property on our me model like this:

mode: {
  type: 'string',
  values: [
    'all',
    'completed',
    'active'
  ],
  default: 'all'
}

It's readable and behaves as you'd expect.

Calculating various lengths/totals

The app spec states we must show counts of "items left" and "items completed," plus we have to be able to know if there aren't any items at all in the collection so we can hide the header and footer.

This means we need to track 3 different calculated totals at all times.

Ultimately if this is state we care about, we want them to be easily readable as part of a model definition. Since we have a me model that contains the mode and has a child collection of todos, it makes sense for it to care about and track those totals. So we'll create session properties for all of those totals too.

In the me model's initialize we can listen to events in our collection that we know will affect these totals, and then we have a single method handleTodosUpdate that calculates and sets those totals.

The totals are quite easy; we check todos.length for totalCount, loop through once to calculate how many items are completed for completedCount, then use simple arithmetic for activeCount.

Just for clarity, we also then set a boolean value for whether all of them are completed or not. This is because the spec states that if you go through and check all the items in the list, that the "check all" checkbox at the top should also check itself. Tracking that state as a separate boolean makes it nice and clear.

So, now our me models looks something like this:

...
initialize: function () {
  // Listen to changes to the todos collection that will
  // affect lengths we want to calculate.
  this.listenTo(this.todos, 'change:completed change:title add remove', this.handleTodosUpdate);
  
  // We also want to calculate these values once on init
  this.handleTodosUpdate();
  ...
},
// Define our session properties
session: {
  activeCount: {
    type: 'number',
    default: 0
  },
  completedCount: {
    type: 'number',
    default: 0
  },
  totalCount:{
    type: 'number',
    default: 0
  },
  allCompleted: {
    type: 'boolean',
    default: false
  },
  mode: {
    type: 'string',
    values: [
      'all',
      'completed',
      'active'
    ],
    default: 'all'
  }
},
// Calculate and set various lengths we're
// tracking. We set them as session properties
// so they're easy to listen to and bind to DOM
// where needed.
handleTodosUpdate: function () {
  var completed = 0;
  var todos = this.todos;
  todos.each(function (todo) {
    if (todo.completed) {
      completed++;
    }
  });
  // Here we set all our session properties
  this.set({
    completedCount: completed,
    activeCount: todos.length - completed,
    totalCount: todos.length,
    allCompleted: todos.length === completed
  });
},
...

At this point we have all the state we want to track for the entire app. None of it is mixed into any of the view logic. We've got an entire completely de-coupled data layer that tracks all state for the app.

You can see the me model in its entirety as currently deployed on github.

Routing

Once we've done all of this state management, the router becomes super simple.

We've already created a mode flag on the me that actually controls everything.

So all we have to do is set the proper mode based on the URL, which we can do like so:

var Router = require('ampersand-router');


module.exports = Router.extend({
  routes: {
    // this matches all urls
    '*filter': 'setFilter'
  },
  setFilter: function (arg) {
    // if we passed one, set it
    // if not set it to "all"
    app.me.mode = arg || 'all';
  }
});

Views

At this point it's really all a matter of wiring things up to the views. The views contain very little actual logic. They simply declare how things should be rendered, what data should be bound where, and turn user actions into changes in our state layer.

For this app, the index.html file contains the layout HTML already. So the main view is just going to attach itself to the <body> tag as you can see in our app.js file, below. We simply hand it the existing document.body and never call render() because it's already there.

var MainView = require('./views/main');
var Me = require('./models/me');
var Router = require('./router');


window.app = {
  init: function () {
    // Model representing state for
    // user using the app. Calling it
    // 'me' is a bit of convention but
    // it's basically 'app state'.
    this.me = new Me();
    
    // Our main view
    this.view = new MainView({
      el: document.body,
      model: this.me
    });

    // Create and fire up the router
    this.router = new Router();
    this.router.history.start();
  }

};

window.app.init();

The views in this particular app handle all bindings declaratively as described by the bindings property of the views. It might feel a tad verbose, but it's also very precise. This way you, as the developer, can decide whether you want to just render things into the template on first render, or whether you want to bind things. It's also useful for publishing re-usable views. Because you don't have to include any templating library as part of your re-usable views.

Templates and views are easily the most debate-inducing portion of modern JS apps, but the main point is that Ampersand.js gives you an agnostic way of doing data binding that's there if you want it, but completely gets out of your way if you'd rather use something like Handlebars or React to handle your view layer.

That's the whole point of the modular architecture of Ampersand.js: optimize for flexibility, install only what you want to use.

For a full reference of all the data binding types you can use, see the reference documentation.

Below are the declarative bindings from the main view with comments describing what each does.

Note that model in this case is the me model. So model.totalCount, for example, is referencing the me.totalCount session property discussed above. If you really prefer tracking state in your view code, it's easy to do so. Simply add a props or session properties just like you would in a model, and everything still works.

It's worth noting that with the way we've declared bindings in the app they still work if you replaced this.el, or if this.model was changed or didn't exist at the time of first render. They would still be set and updated accordingly.

Many times in real apps, these binding declarations are simpler than this example, but on the plus side it serves as a good demo of the types of bindings that are available. Here's the data binding section from our js/views/main.js view:

...

bindings: {
  // Toggles visibility of main and footer
  // based on truthiness of totalCount.
  // Since zero is falsy it won't show if
  // total is zero.
  'model.totalCount': {
    // this is the binding type
    type: 'toggle',
    // this is just a CSS selector
    selector: '#main, #footer'
  },
  // This is how you do multiple bindings
  // to a single property. Just pass an 
  // array of bindings.
  'model.completedCount': [
    // Hides the clear-completed span
    // when there are no completed items
    {
      type: 'toggle',
      // "hook" here is shortcut for 
      // selector: '[data-hook=clear-completed]'
      hook: 'clear-completed'
    },
    // Inserts completed count as text
    // into the span
    {
      type: 'text',
      hook: 'completed-count'
    }
  ],
  // This is an HTML string that we made
  // as a derived (a.k.a. computed) property
  // of the `me` model. This was done this way
  // for simplicity because the target HTML
  // looks like this: 
  // "<strong>5</strong> items left"
  // where "items" has to be correctly pluralized
  // since it's not just text, but not really
  // a bunch of nested HTML it was easier to just
  // bind this as `innerHTML`.
  'model.itemsLeftHtml': {
    type: 'innerHTML',
    hook: 'todo-count'
  },
  // This adds the 'selected' class to the right
  // element in the footer
  'model.mode': {
    type: 'switchClass',
    name: 'selected',
    cases: {
      'all': '[data-hook=all-mode]',
      'active': '[data-hook=active-mode]',
      'completed': '[data-hook=completed-mode]',
    }
  },
  // Bind 'checked' state of `mark-all`
  // checkbox at the top
  'model.allCompleted': {
    type: 'booleanAttribute',
    name: 'checked',
    hook: 'mark-all'
  }
},
...

A few closing thoughts

I'm excited that we were asked to contribute an example to TodoMVC. Big thanks to Luke Karrys, Philip Roberts and Gar for their help/feedback on building the app and to Sindre Sordhus, Addy Osmani, and Pascal Hartig for their hard work on the TodoMVC project, as it's quite useful for comparing available tools.

If you have any feedback ping me, @HenrikJoreteg on twitter or any of the other core contributors for that matter. You can also jump into the #&yet IRC channel on freenode and tell us what you think. We're always working to improve.

We think we've created something that strikes a good balance between flexibility, expressiveness, readability, and power, and we're thrilled about the fast adoption and massive community contribution we've seen in just a few short months since releasing Ampersand.js.

I'll be speaking about frameworks in Brighton at FullFrontal in a few weeks, and then about Ampersand.js at BackboneConf in December. Hope to see you then.

If you like the philosophy and approaches described here, you might also enjoy my book, Human JavaScript. If you want Ampersand.js training for your team get in touch with our training coordinator.

See you on the Interwebz! <3

You might also enjoy reading:

Blog Archives: