Previous: Installation
Next: Post-Mortem
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, Promises, and the use of async/await
.
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:
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. Please note, though, that the coding style has since changed with Ember Octane. The examples below have been updated to reflect the newer syntax.
We'll start off a bare-bones implementation of the feature: within
an action called findStores
, we'll create an async function 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.
import { action } from '@ember/object'; export default class Tutorial0 extends TutorialComponent { result = null; @action async findStores() { let geolocation = this.geolocation; let store = this.store; let coords = await geolocation.getCoords() let result = await store.getNearbyStores(coords); this.set('result', result); } }
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.
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.
import { action } from '@ember/object'; export default class Tutorial1 extends TutorialComponent { result = null; isFindingStores = false; // ++ @action async findStores() { let geolocation = this.geolocation; let store = this.store; this.set('isFindingStores', true); // ++ let coords = await geolocation.getCoords() let result = await store.getNearbyStores(coords); this.set('result', result); this.set('isFindingStores', false); // ++ } }
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.
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.
import { action } from '@ember/object'; export default class Tutorial2 extends TutorialComponent { result = null; isFindingStores = false; @action async findStores() { if (this.isFindingStores) { return; } // ++ let geolocation = this.geolocation; let store = this.store; this.set('isFindingStores', true); let coords = await geolocation.getCoords() let result = await store.getNearbyStores(coords); this.set('result', result); this.set('isFindingStores', false); } }
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.
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:
import { action } from '@ember/object'; export default class Tutorial3 extends TutorialComponent { result = null; isFindingStores = false; @action async findStores() { if (this.isFindingStores) { return; } let geolocation = this.geolocation; let store = this.store; this.set('isFindingStores', true); let coords = await geolocation.getCoords() let result = await store.getNearbyStores(coords); if (this.isDestroyed) { return; } // ++ this.set('result', result); this.set('isFindingStores', false); } }
Now if you click "Find Nearby Stores" and navigate elsewhere, you won't see that pesky error.
Now, are we done?
You might have noticed that we don't have any error handling if
either the getCoords
or getNearbyStores
await
calls throw 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 await
calls raises an error,
isFindingStores
would be stuck to true
, and
there'd be no way to try fetching again.
Let's use a finally
block 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.
import { action } from '@ember/object'; export default class Tutorial4 extends TutorialComponent { result = null; isFindingStores = false; @action async findStores() { if (this.isFindingStores) { return; } let geolocation = this.geolocation; let store = this.store; this.set('isFindingStores', true); try { // ++ let coords = await geolocation.getCoords() let result = await store.getNearbyStores(coords); if (this.isDestroyed) { return; } this.set('result', result); } finally { // ++ if (!this.isDestroyed) { // ++ this.set('isFindingStores', false); // ++ } // ++ } // ++ } }
And there you have it: a reasonably-production ready implementation of finding nearby stores.
Previous: Installation
Next: Post-Mortem