Refactoring With Tasks

Now we're going to build the same functionality using ember-concurrency tasks, starting with the same bare minimum implementation as before, and making incremental improvements.

For reference, here is the bare minimum implementation that we started with before (which only uses core Ember APIs):

export default TutorialComponent.extend({
  result: null,
  actions: {
    findStores() {
      let geolocation = this.get('geolocation');
      let store = this.get('store');

      geolocation.getCoords()
        .then(coords => store.getNearbyStores(coords))
        .then(result => {
          this.set('result', result);
        });
    }
  },
});
Toggle JS / Template
Example

Version 1: Bare Minimum Implementation (with Tasks)

Now let's build the same thing with ember-concurrency tasks:

import { task } from 'ember-concurrency';

export default TutorialComponent.extend({
  result: null,

  findStores: task(function * () {
    let geolocation = this.get('geolocation');
    let store = this.get('store');

    let coords = yield geolocation.getCoords();
    let result = yield store.getNearbyStores(coords);
    this.set('result', result);
  }),
});
Toggle JS / Template
Example

Let's take a moment to point out everything that has changed:

First, instead of using a findStores action, we define a findStores task.

Second, in the template, instead of using onclick={{action 'findStores'}}, we use onclick={{perform findStores}}.

Lastly, instead of using a Promise chain of .then() callbacks, we use the Generator Function Syntax and the yield keyword.

We'll get into much greater detail about how this syntax is used, but for now, the most important thing to understand is that when you yield a promise, the task will pause until that promise fulfills, and then continue executing with the resolved value of that promise.

Let's press onward with the refactor:

Version 2: Add a Loading Spinner (with Tasks)

Rather than defining a separate boolean flag and manually tracking the state of the task, we can use the isRunning property exposed by the task to drive our loading spinner, which means we only need to make a change to the template code; the JavaScript can stay the same:

<button onclick={{perform findStores}}>
  Find Nearby Stores
  {{#if findStores.isRunning}}
    {{! ++ }}
    {{loading-spinner}}
  {{/if}}
</button>

{{#if result}}
  {{#each result.stores as |s|}}
    <li>
      <strong>{{s.name}}</strong>:
      {{s.distance}} miles away
    </li>
  {{/each}}
{{/if}}
Toggle JS / Template
Example

Version 3: Preventing Concurrency (with Tasks)

So far so good, but we still haven't addressed the issue that clicking the button multiple times causes weird behavior due to multiple fetch operations running at the same time.

Rather than putting an if guard at the start of the task, the ember-concurrency way to prevent concurrency is to apply a Task Modifier to the task. The one we want to use is the .drop() modifier, which prevents concurrency by "dropping" any attempt to perform the task while it is already running.

import { task } from 'ember-concurrency';

export default TutorialComponent.extend({
  result: null,

  findStores: task(function * () {
    let geolocation = this.get('geolocation');
    let store = this.get('store');

    let coords = yield geolocation.getCoords();
    let result = yield store.getNearbyStores(coords);
    this.set('result', result);
  }).drop(), // ++
});
Toggle JS / Template
Example

Now when you button mash "Find Nearby Stores", you no longer get the weird behavior due to concurrent fetches.

Version 4: Handling "set on destroyed object" errors (with Tasks)

What about those pesky "set on destroyed object" errors?

Good news! Our code is already safe because ember-concurrency automatically cancels tasks when their host object (e.g. a Component) is destroyed. In our example, if the findStores task is paused at the unresolved getNearbyStores promise right when the user navigates away, the component will be destroyed and the findStores task will stop right where it is and will never hit the line of code with the this.set(), thus avoiding the "set on destroyed object" error.

The ability to cancel a task in mid-execution is one of ember-concurrency's most powerful features, and it is the generator function syntax that makes cancelation possible.

Version 5: Handle Promise Rejection (with Tasks)

Will a promise rejection put our task into an unrecoverable state?

It turns out that, again, we don't need to change any code; if either getCoords or getNearbyStores returned a rejecting promise, the findStores task would stop execution where the error occurred, bubble the exception to the console (so that error reporters can catch it), but from there on the task can be immediately performed / retried again. So, we don't need to change any code.

Final Diff

JavaScript:

export default TutorialComponent.extend({
  result: null,
  findStores: task(function * () {
    let geolocation = this.get('geolocation');
    let store = this.get('store');

    let coords = yield geolocation.getCoords();
    let result = yield store.getNearbyStores(coords);
    this.set('result', result);
  }).drop(),
});
diff


Template:

<button onclick={{perform findStores}}>
  Find Nearby Stores
  {{#if findStores.isRunning}}
    {{loading-spinner}}
  {{/if}}
</button>

{{#if result}}
  {{#each result.stores as |s|}}
    <li>
      <strong>{{s.name}}</strong>:
      {{s.distance}} miles away
    </li>
  {{/each}}
{{/if}}
diff

Conclusion

This was a very successful refactor. We were able to remove a lot of ugly boilerplate and defensive programming code, and what we're left with is very clean, concise, safe, and stress-free code.