Previous: Post-Mortem
Next: Task Function Syntax
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):
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); } }
Now let's build the same thing with ember-concurrency tasks:
import { task } from 'ember-concurrency'; export default class Tutorial6 extends TutorialComponent { result = null; @task *findStores() { let geolocation = this.geolocation; let store = this.store; let coords = yield geolocation.getCoords(); let result = yield store.getNearbyStores(coords); this.set('result', result); } }
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 an async
function with await
calls, 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 or async function, the task will
pause until that promise or async function fulfills,
and then continue executing with the resolved value of that promise or async function.
Semantically, it is roughly the same as using await
, but using
generator functions allows controlling the execution in a way that using
native, browser-managed async/await
does not.
Let's press onward with the refactor:
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 {{on "click" (perform this.findStores)}} type="button"> Find Nearby Stores {{#if this.findStores.isRunning}} {{! ++ }} <LoadingSpinner /> {{/if}} </button> {{#if this.result}} {{#each this.result.stores as |s|}} <li> <strong>{{s.name}}</strong>: {{s.distance}} miles away </li> {{/each}} {{/if}}
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 class Tutorial8 extends TutorialComponent { result = null; @task({ drop: true }) // ++ *findStores() { let geolocation = this.geolocation; let store = this.store; let coords = yield geolocation.getCoords(); let result = yield store.getNearbyStores(coords); this.set('result', result); } }
Now when you button mash "Find Nearby Stores", you no longer get the weird behavior due to concurrent fetches.
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
await
call 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.
Will a promise rejection/async exception 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
throw an exception,
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.
JavaScript:
import { task } from 'ember-concurrency'; export default class Tutorial9 extends TutorialComponent { result = null; @task({ drop: true }) *findStores() { let geolocation = this.geolocation; let store = this.store; let coords = yield geolocation.getCoords(); let result = yield store.getNearbyStores(coords); this.set('result', result); } }
Template:
<button {{on "click" (perform this.findStores)}} type="button"> Find Nearby Stores {{#if this.findStores.isRunning}} <LoadingSpinner /> {{/if}} </button> {{#if this.result}} {{#each this.result.stores as |s|}} <li> <strong>{{s.name}}</strong>: {{s.distance}} miles away </li> {{/each}} {{/if}}
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.
Previous: Post-Mortem
Next: Task Function Syntax