Debounced Type-Ahead Search

This advanced example combines multiple ember-concurrency concepts to build a basic type-ahead search field with the following features:

  • Debouncing: the browser won't make network requests until the user has stopped typing for more than 250ms. This is accomplished by combining the restartable task modifier with a yield timeout(250) at the beginning of the task.
  • Fetch cancelation: if the user starts typing while a prior fetch request is underway, that fetch request will be canceled to save network resources (this is accomplished via the try / finally cancelation pattern).
  • Use Derived State to display both a loading spinner and the final search results without using a single .set().
Live Example

Please mind the GitHub API quota :)

JavaScript
const DEBOUNCE_MS = 250;
export default class AutocompleteController extends Controller {
  @restartableTask *searchRepo(term) {
    if (isBlank(term)) {
      return [];
    }

    // Pause here for DEBOUNCE_MS milliseconds. Because this
    // task is `restartable`, if the user starts typing again,
    // the current search will be canceled at this point and
    // start over from the beginning. This is the
    // ember-concurrency way of debouncing a task.
    yield timeout(DEBOUNCE_MS);

    let url = `https://api.github.com/search/repositories?q=${term}`;

    // We yield an AJAX request and wait for it to complete. If the task
    // is restarted before this request completes, the XHR request
    // is aborted (open the inspector and see for yourself :)
    let json = yield this.getJSON.perform(url);
    return json.items.slice(0, 10);
  }

  @task *getJSON(url) {
    let controller = new AbortController();
    let signal = controller.signal;

    try {
      let response = yield fetch(url, { signal });
      let result = yield response.json();
      return result;

      // NOTE: could also write this as
      // return yield fetch(url, { signal }).then((response) => response.json());
      //
      // either way, the important thing is to yield before returning
      // so that the `finally` block doesn't run until after the
      // promise resolves (or the task is canceled).
    } finally {
      controller.abort();
    }
  }
}
Template
<label>
  Search GitHub...

  <input type="text" {{on "input" (perform this.searchRepo value="target.value")}}
         placeholder="e.g. machty/ember-concurrency">
</label>

{{#if this.searchRepo.isRunning}}
  <LoadingSpinner />
{{/if}}

<ul>
  {{#each this.searchRepo.lastSuccessful.value as |repo|}}
    <li>{{repo.full_name}}</li>
  {{/each}}
</ul>