CodeBrief

Anatomy of an Ember.js App Part I Redux: Routing and Outlets

Update: Ironically, this "redux" is once again out of date. Please consult the new Ember.js Router Guides

Four months ago, in February, I wrote a blog post detailing a strategy for handling routing and layout in Ember.js. This was at a time when there was no support for these features in Ember's core. Today, however, this is not the case. Ember now has the notion of a Router and an {{outlet}} handlebars helper. These two features supercede the libraries in the previous post (Ember Layout has been completely deprecated and Ember RouteManager has been temporarily frozen.)

The bulk of this post will deal with Ember's native router implementation. You can also skip ahead and see an example application (a rewrite of the same example in the previous post). At the end, I will also go into detail about the differences between Ember.Router and my earilier routing implementation, Ember.RouteManager.

Ember's Router

At the core of Ember's routing solution is Ember.Router. It is a subclass of Ember.StateManager that handles serializing/deserializing the application's state to/from the current URL. By default, states defined within the router that extend Ember.Route (or include the Ember.Routable mixin) can have a route property defined which can be used for route definitions.

Here is an example from which the following code snippet was extracted:

window.App = Ember.Application.create({

  [...]

  Router: Ember.Router.extend({
    root: Ember.Route.extend({
      doHome: function(router, event) {
        router.transitionTo('home');
      },
      doSections: function(router, event) {
        router.transitionTo('sections.index');
      },
      doItems: function(router, event) {
        router.transitionTo('items');
      },
      home: Ember.Route.extend({
        route: '/',
        connectOutlets: function(router, event) {
          router.get('applicationController').connectOutlet('home');
        }
      }),

      [...]

    })
  })

});

App.initialize();

The above code defines a home state in the router which has a route of /. As the name and code suggest, when the application is initialized and the current url is /, the home state will automatically be transitioned to. Conversely, you might assume that to programmatically change the state to home you would simply change the current url to '/', but this is not the case. Instead you would call the doHome method on the router. This could be accomplished using an action handlebars helper: <a {{action doHome}}>Home</a>.

In contrast with other routing solutions, Ember's routing implementation eschews the notion of truth in url and instead treats it as a serialization of the application's state. Tom Dale describes the pitfalls of other approaches in a blog post:

This approach is not ideal for several reasons.

First, it couples your URLs to your application code. Changing segment names means you have to go through your entire app and update the hardcoded strings. It also requires that, if you want to enter a new state, you must go consult the router to be reminded what the particular URL is. Breaking encapsulation this way quickly leads to out-of-control code that is hard to maintain.

[...]

The other problem with describing state in terms of URLs is that there is a cumbersome and pointless serialization step. In my JavaScript, I am dealing with controllers and model objects. Having to turn them into a string form just to get them to interact is brittle and unnecessary.

Serialization of application state aside, I have also previously written about the benefits of using a state machine to handle routing.

Nested Routes and Parameters

Since all routes are states, they can also be nested. As would be expected, nested routes correspond to fragments of the current url:

items: Ember.Route.extend({
  route: '/items',
  index: Ember.Route.extend({
    route: '/'
  }),
  item: Ember.Route.extend({
    route: '/:item_id',
    connectOutlets: function(router, context) {
      var item = router.getPath('itemsController.content').objectAt(context.item_id);
      router.get('itemController').set('content', item);
      router.get('applicationController').connectOutlet('item');
    }
  }),
  connectOutlets: function(router, context) {
    router.get('applicationController').connectOutlet('items');
  },
  doItem: function(router, event) {
    router.transitionTo('item', {item_id: event.context.id});
  }
}),

In the above example, the items state has a route of /items as well as a child state called item that has its own route defined as /:item_id. During routing, these two routes will be composed and a route of /items/1 will match the item state.

This also introduces the notion of route parameters. Routes defined with the :parameter_name syntax act as wildcards, with the matched part of the URL being used to populate a property on the context.

One additional caveat is that only leaf states are routable. In the above example, the items state is not directly routable. Instead, we must define an index leaf state to function as an index action for all the items. This requirement is necessary to make routes explicit and to remove the ambiguity in the routability of parent states.

Controllers and Outlets

By now you must be wondering how the actual view layer changes in response to a state change. The secret here is the connectOutlets method:

  connectOutlets: function(router, context) {
    router.get('applicationController').connectOutlet('items');
  },

Let's look at the corresponding template for the main application view:

<script type="text/x-handlebars" data-template-name="application">
  <div class="navbar navbar-fixed-top">
    <div class="navbar-inner">
      <div class="container">
        <a class="brand" href="#">Ember.js Router Example</a>
        <div class="nav-collapse">
          <ul class="nav">
            <li class="home" {{bindAttr class="isHome:active"}}><a {{action "doHome"}}>Home</a></li>
            <li class="sections" {{bindAttr class="isSections:active"}}><a {{action "doSections"}}>Sections</a></li>
            <li class="items" {{bindAttr class="isItems:active"}}><a {{action "doItems"}}>Items</a></li>
          </ul>
        </div>
      </div>
    </div>
  </div>

  <div class="container">
    {{outlet}}
  </div>
</script>

Notice the {{outlet}} helper near the bottom of the template. Outlets are essentially placeholders for views. For an outlet with no parameters, it's contents will directly reflect the value of the view property of the corresponding controller. When the line router.get('applicationController').connectOutlet('items'); is hit, the applicationController's connectOutlet method will do the following:

  • Look for a controller corresponding to the name. In this case, itemsController.
  • Instantiate a view corresponding to the name. In this case, create an instance of ItemsView.
  • Set the view property of the controller to the newly instantiated view.

Thus, the view layer is changed when the state is changed.

That's it for this brief overview of routing in Ember.js. Also, be sure to check out the example.

Differences From RouteManager

This section is for those of you curious what exactly has changed between the last post and this post.

Ember Layout was originally created to give the exact same functionality as the {{outlet}} helper (was called {{dynamicView}}). Needless to say, it is simply not needed anymore.

Ember RouteManager was my attempt at a routing solution for Ember.js (at the time there was none). Both Ember.RouteManager and Ember.Router are extensions of Ember.StateManager, the main difference being that Ember.Router treats URLs as actions whereas RouteManager treats URLs as a mapping to a view state of the application.

I'm not 100% sure I agree with the routing semantics of Ember.Router. Nevertheless, one of its benefits is that is gives a stronger opinion on creating controllers for your views. In this spirit of convention, I'm temporarily going to freeze development on RouteManager.

comments powered by Disqus