Async Computed Properties in Ember

January 2, 2019 7:00 PM Chicago, IL

Computed properties are flexible, efficient, and easy to understand. When I was first learning Ember, they reminded me of what I enjoyed so much about Excel - my first programming 🥰.

...and then I met ember-concurrency.

I didn't want to choose between them (computed properties and ember concurrency) — I wanted to define computed properties with generator functions and have the backing ember-concurrency task built seamlessly.

Like this:

// ✂️

tempForecast: computedTask('selectedDate', function * () {
  const {
    date,
    buildForecastUrl,
  } = this;
  const url = buildForecastUrl(date);
  const response = yield fetch(url);
  if (response.status === 200) {
    const json = yield response.json();
    return json['temperature'];
  }
})

// ✂️
Forecast Date
Temperature

With computed tasks, there are no promises to deal with in the template, and only a single computed task explicitly declared in the component.

// ✂️

{{#if this.tempForecastTask.isRunning}}
  {{fa-icon "sun" spin=true}}
{{else}}
  {{this.tempForecast}}
  <span class="text-grey">
    &#8457;
  </span>
{{/if}}

// ✂️

How does it work? I added this function in my utils that builds the task and computed property.

// manifesto/utils/computed-task.js

import {
  defineProperty
} from '@ember/object';

import {
  task
} from 'ember-concurrency';

import ComputedProperty from '@ember/object/computed';

import {
  expandProperties
} from '@ember/object/computed';

const _CP = ComputedProperty;

function parseArgs(args) {
  return {
    dks: args.slice(0, -1),
    gf: args[args.length - 1],
  };
}

const ComputedTaskProperty = function (...args) {
  const { dks, gf } = parseArgs(args);

  return _CP.call(this, function(pn) {
    const tn = `${pn}Task`;

    const isInitKn = [
      'isCtInit',
      pn
    ].join('-');

    const isInit = this.get(isInitKn);

    const vkn = [
      tn,
      'lastSuccessful',
      'value'
    ].join('.');

    if (!isInit) {
      defineProperty(
        this,
        tn,
        task(gf).restartable()
      );

      this.addObserver(vkn, () => {
        this.notifyPropertyChange(pn);
      });

      this.get(tn).perform();

      const eks = [];

      dks.forEach((dk) => {
        expandProperties(dk, (p) => {
          eks.push(p);
        });
      });

      eks.forEach((ek) => {
        this.addObserver(ek, () => {
          this.get(tn).perform();
        });
      });

      this.set(isInitKn, true);
    }

    return this.get(vkn);
  });
}

ComputedTaskProperty.prototype = Object.create(
  ComputedProperty.prototype
);

export default function computedTask(...args) {
  return new ComputedTaskProperty(...args);
}

It's not perfect, but I've used it for a while in our applications, and its been fine™.

A few final notes:

  • Why didn't I release it as an add on?
    Because I'm a lousy maintainer.
  • Why aren't there more options?
    Because we haven't needed them.
  • Why do you have to "get" the computed task in init?
    Because it's generated lazily and the task wouldn't have been created if I hadn't "gotten" it.
  • Should something like this be provided by ember-concurrency?
    Maybe? I'd like it, but given that I'm haven't even released it as an add on, who am I to say.