A lighter weight implementation of link-to

02 February 2018

A while ago I showed how to use the router service to copy front-end generated URLs to the clipboard without transitioning to the routes that generate those URLs. In this post, I want to build on top of that scenario and implement Ember's built-in link-to using a great little add-on, ember-href-to and some custom code.

Why do that?

There's nothing wrong with link-to in most cases but each link-to invocation creates a component instance which on a page with a high number of links can lead to decreased performance. Gavin Joyce realized this while working on the Ember app at Intercom and implemented (with several other contributors) a replacement for link-to that provides a helper that generates the URL (and handles the click events) for Ember generated routes.

There's also an argument to be made (and I'm going to make it at the end of this post) about having more flexibility when you decompose a feature to its composing parts.

Ok, let's use the ember-href-to addon to start replacing the band links in our app.

Replace link-to calls with a tags and href-to

The band template currently looks as below:

 1<!-- app/templates/bands.hbs -->
 2<div class="col-md-4">
 3  (...)
 4  {{#each model as |band|}}
 5    {{#link-to "bands.band" band class="list-group-item band-link"}}
 6      {{capitalize band.name}}
 7      <button class="btn btn-default btn-xs share-button" onclick={{action 'shareBandURL' band.id}}>Share</button>
 8    {{/link-to}}
 9  {{/each}}
10  (...)
11</div>

You can see we create the links for each band with a link-to on line 5. Let's install the ember-href-to add-on so that we have access to the href-to helper:

$ ember install ember-href-to

We can now rewrite the link-to line:

 1<!-- app/templates/bands.hbs -->
 2<div class="col-md-4">
 3  (...)
 4  {{#each model as |band|}}
 5    <a href={{href-to "bands.band" band}} class="list-group-item band-link">
 6      (...)
 7    </a>
 8  {{/each}}
 9</div>

This works but no link is marked as active:

Works but no link is active

That makes sense as one of the extras you get with link-to is that the appropriate links are automatically marked as active (by adding a configurable 'active' class to them). The add-on states that clearly in its README:

As {{href-to}} simply generates a URL, you won't get automatic active class bindings as you do with {{link-to}}.

Fair enough. Armed with a full-fledged router service, this is not difficult to add ourselves, so let's get to it.

Restore the active class for links

Let's create a helper that returns whether a certain route (including models and query params) is active or not. We'll have to create a class-based helper as we'll need access to the router service and simple, functional helpers cannot do that.

Ember CLI currently doesn't have a way to create class-based helpers (I started work on adding it but haven't got to finishing it yet) so we'll do it manually:

 1// app/helpers/is-active.js
 2import Helper from '@ember/component/helper';
 3import { inject as service } from '@ember/service';
 4import { observer } from '@ember/object';
 5
 6export default Helper.extend({
 7  router:  service(),
 8
 9  compute(params) {
10    return this.get('router').isActive(...params);
11  }
12});

Believe it or not, that's really it. Since the router service has an isActive method, we just had to enable calling it in the template layer - via a helper. We can now add the class we'd like based on the result of the is-active call:

 1<!-- app/templates/bands.hbs -->
 2<div class="col-md-4">
 3  (...)
 4  {{#each model as |band|}}
 5    <a href={{href-to "bands.band" band}}
 6       class="list-group-item band-link {{if (is-active 'bands.band' band) 'active'}}">
 7    (...)
 8    </a>
 9  {{/each}}
10</div>

This should work now, let's see it in action:

Link activeness is not updated

Oops, the active link is correctly marked as such but when we go to a new band's page (when we transition to a new route), the activeness is not updated.

Update link activeness for each transition

The problem is that our is-active helper doesn't know it should recalculate its output (run its compute function) as none of its inputs (the bands.band string or the band object) change. There's a way to trigger a "manual" update and it's through the helper's recompute function. So when should we call recompute? Whenever the router makes a transition, in other words, whenever the URL changes.

Fortunately, the router service also exposes a currentURL property so we can react to its changes:

 1// app/helpers/is-active.js
 2import Helper from '@ember/component/helper';
 3import { inject as service } from '@ember/service';
 4import { observer } from '@ember/object';
 5
 6export default Helper.extend({
 7  router:  service(),
 8
 9  compute(params) {
10    return this.get('router').isActive(...params);
11  },
12
13  onURLChange: observer('router.currentURL', function() {
14    this.recompute();
15  }),
16});

And now we're really done, we have successfully assembled link-to from its constituent parts:

Fully functional links

The argument for added flexibility via composition

With link-to you get a package deal: you'll get an a tag through a component and an active class set on it, when appropriate. However, in some cases, you might need the active class on a parent tag (or not need it at all). People have been struggling with this (when integrating with Bootstrap, for example) and Alex Speller even wrote an add-on to tackle this problem.

Having command over the parts that make the whole is a great benefit: you can assemble the full functionality from its parts when you need to - and only when you need to, I should add.

Share on Twitter