Our first task: an autocomplete searchbox
Transcript
In this video, we'll look at a naive implementation of an autocomplete searchbox, and use an Ember Concurrency Task to improve it.
Here's our current implementation:
// components/autocomplete-searchbox.js import Ember from 'ember'; export default Ember.Component.extend({ init() { this._super(...arguments); this.set('results', []); }), actions: { updateResults(query) { Ember.$.getJSON('/query', { query }).then(results => { this.set('results', results); }); } } });
// templates/components/autocomplete-searchbox.hbs <p>Search:</p> <input oninput={{action 'updateResults' value='target.value'}} <ul> {{#each results as |result|}} <li>{{result}}</li> {{/each}} </ul>
The
input triggers the
updateResults action, which fires off a network request. When the server responds, the
results list updates and the component rerenders.
We've hooked up a simple mock server that takes one second to respond, so we can see there's a small delay between when we type and when the list updates.
This component works, but it has a bug. To demonstrate it, let's add a button that shows and hides the component:
// templates/application.hbs <button {{action (toggle 'showSearch' this)}}>Toggle search</button> {{#if showSearch}} {{autocomplete-searchbox}} {{/if}}
If we toggle the button and type into the searchbox, we see that everything still works.
Let's do it again, but this time, we'll enter text but then hide the searchbox before our server responds. We can see in the console that this causes our app to throw an exception:
calling set on destroyed object. Clicking on the exception brings us to the problematic line in our component: our callback was trying to set an instance property on the component using
this.set, but because of the toggle, the instance was already torn down.
This is exactly the kind of problem Ember Concurrency was designed to solve. Let's see how a Task can help us fix this.
First, we'll install the Ember Concurrency library:
ember install ember-concurrency
Next, we'll move the server query into a separate function:
// components/autocomplete-searchbox.js actions: { updateResults(query) { this.queryServer(query); } }, queryServer: function(query) { Ember.$.getJSON('/query', { query }).then(results => { this.set('results', results); }); }
Tasks are defined using the
task helper, which can be imported from
ember-concurrency. Tasks take a generator function as an argument, so we'll take our existing function, wrap it in
task, and then make the function a generator. We do that by adding an asterisk after the
function keyword:
import { task } from 'ember-concurrency'; queryServer: task(function * (query) { Ember.$.getJSON('/query', { query }).then(results => { this.set('results', results); }); })
Generators are a new ES6 language feature that allow us to start executing a function, but then pause it before it's finished. We'll see how to do that in just a moment. For now, it's enough to know that anything you can do inside a normal function, you can also do inside of a generator.
We've wrapped our
queryServer function in a task, but there's one more change we need to make to get our code executing again. The task helper returns a special object, so we can't invoke it like a normal function. Instead, we'll need to get our task, and then call
perform on it.
So, we'll change our
updateResults action to look like this:
updateResults(query) { this.get('queryServer').perform(query); }
Our code is working again - but we actually haven't fixed anything! Even though our
queryServer function is now a Task, we have the same bug as before.
Now it's time for the fun part. The magic of Tasks lies in isolating the asynchronous parts of our code - the code that takes a long time to run. Looking at our
queryServer task, which part of it takes a long time?
queryServer: task(function * (query) { Ember.$.getJSON('/query', { query }).then(results => { this.set('results', results); }); })
We can see it's the line that executes
getJSON.
getJSON is an asynchronous function that hits the network, and executes its callback at some point in the future.
To tell our Task that this part of the code is what takes time, we'll use the
yield keyword.
yield is actually a feature of generator functions, not the Ember Concurrency library; but it's a key part of what makes the library work. Ember Concurrency uses
yield to decide whether it should continue execution of a task, or abort. In the next video we'll do a deep dive on generators and yield, but first, let's finish our task.
yield can feel a bit like magic. If we
yield a promise, we get to work with the results on the next line, like this:
queryServer: task(function * (query) { let results = yield Ember.$.getJSON('/query', { query }); // work with `results` })
This looks like synchronous code - there's no callback! This is the beauty of generators and yield. It lets us write asynchronous code as if it were synchronous.
We can now move the code from our original callback and put it on the next line:
queryServer: task(function * (query) { let results = yield Ember.$.getJSON('/query', { query }); this.set('results', results); })
And we've actually just solved our bug! Let's see it in action.
The component works just like before, but this time if we hide the component before the request finishes, we don't see the exception thrown. This is because Ember Concurrency pauses at the yielded code, and only continues if the component is still on the page. When we toggle the button and the component is torn down, the task is canceled, and the last line simply never executes.
This is a shining example of the benefits of structured concurrency: we're able to write long-running code that is naturally bounded by its parent. Our programming model is aligned with our mental model, which makes it much easier to manage concurrent activities in our application.
We've now seen how Ember Concurrency helps us avoid bugs while writing clean, easy-to-read code. Next, let's learn about the awesome ES6 language feature that makes all of this possible: the generator function.