Skip to content

Instantly share code, notes, and snippets.

@Joelkang
Last active June 5, 2018 00:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Joelkang/726e0addc759ef9e18d7981b600d4d82 to your computer and use it in GitHub Desktop.
Save Joelkang/726e0addc759ef9e18d7981b600d4d82 to your computer and use it in GitHub Desktop.
Intermediate Ember Routing

#Introduction

This gist walks through some best-practices for intermediate routing architecture, augmenting the information already covered in the guides. It will cover:

  • Nested routes and outlet placement
  • Resuing templates for multiple routes
  • Normalising routes

By the end of the gist, you should be able to make informed decisions about how to structure your routes and their templates. This guide is not meant to provide all the answers, but to help you understand the tools available to you for architecting your app.

Nested routes and outlet placement

Routing often feels magical to newcomers to Ember because the link between each route and what is rendered is not the most obvious. Ths most important thing to note, is that Index Routes come for free the moment you specify child routes at that level.

From the guides:

For example, if you write a simple router like this:

Router.map(function() {
  this.route('favorites');
});

It is the equivalent of:

Router.map(function() {
  this.route('index', { path: '/' });
  this.route('favorites');
});

At this point, you will have access to 2 routes: index and favorites. Now, if you pass a function defining any child routes, you'll automagically also get an index child route, resulting in 3 accessible routes:

Router.map(function() {
  this.route('index', { path: '/' });
  this.route('favorites', function () {
    //this.route('index', { path: '/' }); This comes for free!
    this.route('new', { path: '/new' });
  });
});

This means that when you go to /favorites, the route you're actually hitting is favorites.index. If you do not specify any child routes in favorites, then the favorites route will be a leaf route and there will not be, by default, a favorite.index route.

Rendering into outlets

Now that we know what route we're going to, how do we know what actually gets rendered in the page? The guides explain that "Each template will be rendered into the {{outlet}} of its parent route's template". I've found it easier to understand it as the template for each child route is rendered into the {{outlet}} helper of the template for that route;

Given the above route map, when you go to / in your browser, Ember's router will route you to the index route, which renders the templates/application.hbs template.

When you go to /favorites in your browser, Ember's router will route you to the favorites.index route, which renders:

  1. templates/application.hbs, which, if it has an {{outlet}}, will render into that {{outlet}}:
  2. templates/favorites.hbs, which, if it has an {{outlet}}, will render into that {{outlet}}:
  3. templates/favorites/index.hbs, and so forth.

If you don't have templates/favorites/index.hbs, Ember will render the default template into the {{outlet}} in templates/favorites.hbs. And guess what, the default template is {{outlet}}.

At the same time, if templates/favorites.hbs does not contain an {{outlet}}, templates/favorites/index.hbs (or any templates for child routes for that matter) will simply not be rendered.

With this in mind, it should now be easier to rationalise which parts of your app are shared across child routes and which are child-specific. For example, if you have multiple child routes such as favorites.index, favorite.edit and favorite.new, and there are Components that don't change across those children (navigation and filters come to mind), you'll want to put them in the favorite.hbs template so that as you navigate across the children routes, the shared Component does not need to re-render each time.

Re-using templates for multiple routes

You'll notice that I'm very careful to say each that there are templates for a route as oppose to each route having a template. This is because it is not always the case that there's a 1-to-1 mapping between templates/routeName.hbs and routes/routeName.js. While the Ember Resolver will start look for a template that corresponds to the route's name, there are cases in which you might not want the template in that path.

Let's extend our route map to showcase such an instance:

Router.map(function() {
  this.route('index', { path: '/' });
  this.route('favorites', function () {
    this.route('new', { path: '/new' });
    this.route('edit', { path: '/:favorite_id' });
  });
});

Now, we have a means of editing a favorite, and creating a new one. In many apps, the UIs for doing both of these actions are similar (or even the same), and we don't want to create duplicate templates when one is sufficient.

Here, we can define templates/favorites/edit.hbs that comprises our forms for editing an existing model, and re-use that template for the favorites.new route using the renderTemplate hook:

// routes/favorites/edit.js
import Ember from 'ember';
export default Ember.Route.extend({
  model(params){
    return this.store.findRecord('favorite', pararms.favorite_id);
  }
});

// routes/favorites/new.js
import Ember from 'ember';
export default Ember.Route.extend({
  model(){
    return this.store.createRecord('favorite');
  },
  renderTemplate(controller, model) {
    this.render('favorites.edit', {
      model
    });
  }
});

Doing this, however, is an indication that the templates/favorites/edit.hbs should probably become a Component that you can reuse. You can then simply drop this Component into your templates/favorites/edit.hbs and templates/favorites/new.hbs files. The main benefit of extracting this out into a Component is that you are declaring that it is reusable (and thus reused).

Calling this.render with a template that is named differently from the route also establishes an implicit dependency: if one day the favorites.edit route is deprecated, it's not immediately obvious that the favorite.new route requires the favorite/edit.hbs to be present for rendering. Using a Component here then removes this implicit dependency as a potential source of failure.

Normalising Routes

The guides briefly cover the use case in which you may have multiple sources of data that you'll want to render your templates with. By simply returning an RSVP.hash, {{model}} in your template thus becomes a simple hash of the properties that you pass into the RSVP.hash.

It is important that you are aware that this comes with its own tradeoffs. Specifically recall that when using the {{link-to}} helper, the model hook does not run if you supply an object an as argument for the desired route.

If the edit route is now

// routes/favorites/edit.js
import Ember from 'ember';
export default Ember.Route.extend({
  model(params){
    return Ember.RSVP.hash({
      favorite: this.store.findRecord('favorite', pararms.favorite_id),
      pokemon: this.store.findAll('pokemon')
    });
  }
});

Then, clicking on a link generated with {{link-to 'favorites.edit' 123}} will bring you to the favorites.edit route, which will render templates/favorites/edit with a {{model}} value of

{
 favorite: {
   id: 123,
   attribute1: 'value1',
   ...
 },
 pokemon: [DS.Model...]
}

That is, {[link-to 'favorites.edit' 123}} will cause the model() hook to run with favorite_id having the value of 123.

However, if you're passing a model object into the helper, clicking the link {{link-to 'favorites.edit myModel}} will still bring you to the favorites.edit route, but the route will bypass the model() hook and simply render templates/favorites/edit with a {{model}} value of myModel. If myModel does not have the same shape as the template expects (i.e. it doesn't have the favorite and pokemon fields with the right data types / model types), your template will not render as expected.

My advice is for any route that expects a param to pivot on, its model() hook should return exactly what the route purpots to return. favorite/123 should always return in its model() hook, then, a single favorite model and nothing else. This way, it is obvious what is being passed into the template via the {{model}} helper.

For data that you might want to fetch outside of the model proper, consider either loading it one level above in the parent route, if appropriate, or use the beforeModel() and afterModel() hooks. Since the store is a singleton Service, you can retrieve those values later in your controller or Components for rendering. If you prefer the performance gain of firing off multiple async calls for data at once, you can still use RSVP.hash as long as you resolve the promise to the sole model that your template expects:

// routes/favorites/edit.js
import Ember from 'ember';
export default Ember.Route.extend({
  model(params){
    return Ember.RSVP.hash({
      favorite: this.store.findRecord('favorite', pararms.favorite_id),
      pokemon: this.store.findAll('pokemon')
    }).then(hash => {
      if (hash.favorite.state === 'reject') {
        throw new Error(`Could not find a favorite with the id ${params.favorite_id}`);
      }
      return hash.favorite.value;
    });
  }
});

That said, all of this is my opinion, and you can choose to follow or ignore it as long as you know what you are doing. If you prefer to construct your own object hash to pass into a {{link-to}} helper so that your route's model() hook can return an RSVP.hash, know that you'll have to do this everywhere you want to bypass the model hook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment