Previous: Installation

Next: Post-Mortem

Introduction to ember-concurrency

To demonstrate the kinds of problems ember-concurrency is designed to solve, we'll first implement a basic example of loading data in a Component using only core Ember APIs. Then we'll introduce ember-concurrency tasks as part of a refactor.

This tutorial (and ember-concurrency itself) assumes that you have reasonable familiarity with Ember's core APIs, particularly surrounding Components, templates, actions, and Promises.

For our use case, we're going to implement a Component that fetches and displays nearby retail stores. This involves a two-step asynchronous process:

  1. It uses geolocation to find the user's latitude/longitude coordinates, and then:
  2. It forwards those coordinates to the server to fetch a list of nearby restaurants.

This is basically the same example demonstrated in the EmberConf 2017 ember-concurrency talk; take a look if you prefer a video alternative to this tutorial.

Version 1: Bare Minimum Implementation

We'll start off a bare-bones implementation of the feature: within an action called findStores, we'll create a Promise chain that fetches the coordinates from a geolocation service and passes those coordinates to a store's getNearbyStores method, which eventually gives us an array of stores that we stash on the result property so that the stores can be displayed in the template.

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

This first implementation works, but it's not really production-ready. The most immediate problem is that there's no loading UI; the user clicks the button and it seems like nothing is happening until the results come back.

Version 2: Add a Loading Spinner

We'd like to display a loading spinner while the code is fetching nearby stores. In order to do this, we'll add an isFindingStores property to the component that the template can use to display a spinner.

We'll use ++ comments to highlight newly added code.

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

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

This is certainly an improvement, but strange things start to happen if you click the "Find Nearby Stores" button many times in a row.

The problem is that we're kicking off multiple concurrent attempts to fetch nearby locations, when really we just want only one fetch to be running at any given time.

Version 3: Preventing Concurrency

We'd like to prevent another fetch from happening if one is already in progress. To do this, just need to add a check to see if isFindingStores is true, and return early if so.

export default TutorialComponent.extend({
  result: null,
  isFindingStores: false,
  actions: {
    findStores() {
      if (this.isFindingStores) { return; } // ++

      let geolocation = this.get('geolocation');
      let store = this.get('store');

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

Now it is safe to tap the "Find Nearby Stores" button. Are we done?

Unfortunately, no. There's an important corner case we haven't addressed yet: if the component is destroyed (because the user navigated to a different page) while the fetch is running, our code will throw an Error with the message "calling set on destroyed object".

You can actually verify that this happening by opening your browser's web inspector, clicking "Find Nearby Stores" from the example above, and then quickly clicking this link before the store results have come back.

Version 4: Handling "set on destroyed object" errors

The problem is that it's possible for our promise callback (the one that sets result and isFindingStores) to run after the component has been destroyed, and Ember (and React and many others) will complain if you try and, well, call set() on a destroyed object.

Fortunately, Ember let's us check if an object has been destroyed via the isDestroyed flag, so we can just add a bit of defensive programming to our promise callback as follows:

export default TutorialComponent.extend({
  result: null,
  isFindingStores: false,
  actions: {
    findStores() {
      if (this.isFindingStores) { return; }

      let geolocation = this.get('geolocation');
      let store = this.get('store');

      this.set('isFindingStores', true);
      geolocation.getCoords()
        .then(coords => store.getNearbyStores(coords))
        .then(result => {
          if (this.isDestroyed) { return; } // ++
          this.set('result', result);
          this.set('isFindingStores', false);
        });
    }
  },
});
Toggle JS / Template
Example

Now if you click "Find Nearby Stores" and navigate elsewhere, you won't see that pesky error.

Now, are we done?

Version 5: Handle Promise Rejection

You might have noticed that we don't have any error handling if either the getCoords or getNearbyStores promises reject with an error.

Even if we were too lazy to build an error banner or popup to indicate that something went wrong (and we are), the least we could do is make sure that our code gracefully recovers from such an error and doesn't wind up in a bad state. As it stands, if one of those promises rejected, isFindingStores would be stuck to true, and there'd be no way to try fetching again.

Let's use a finally() handler to make sure that isFindingStores always gets set to false, regardless of success or failure. Unfortunately, this also means we have to duplicate our isDestroyed check.

export default TutorialComponent.extend({
  result: null,
  isFindingStores: false,
  actions: {
    findStores() {
      if (this.isFindingStores) { return; }

      let geolocation = this.get('geolocation');
      let store = this.get('store');

      this.set('isFindingStores', true);
      geolocation.getCoords()
        .then(coords => store.getNearbyStores(coords))
        .then(result => {
          if (this.isDestroyed) { return; }
          this.set('result', result);
        })
        .finally(() => {                      // ++
          if (this.isDestroyed) { return; }   // ++
          this.set('isFindingStores', false); // ++
        });
    }
  },
});
Toggle JS / Template
Example

And there you have it: a reasonably-production ready implementation of finding nearby stores.





Previous: Installation

Next: Post-Mortem