Array Computed Properties

David J. Hamilton

@hjdivad

github.com/hjdivad

Recap: What is a Computed Property?

“… computed properties let you declare functions as properties. You create one by defining a computed property as a function, which Ember will automatically call when you ask for the property. You can then use it the same way you would any normal, static property.”
emberjs.com/guides/object-model/computed-properties

A computed property is a dynamic value computed from its dependent properties.

When a dependent property is changed, the computed property is completely invalidated.

A simple example


  var Person = Ember.Object.extend({
    loudName: function () {
      return this.get('name').toUpperCase();
    }.property('name')
  });
  var david = Person.create({ name: 'David' });

  david.get('loudName') //=> "DAVID"

  david.set('name', 'David J. Hamilton')

  // Re-run `loudName`
  david.get('loudName') //=> "DAVID J. HAMILTON"
            

What about arrays?


  function makeLoud(str) { return str.toUpperCase(); }

  var obj = Ember.Object.extend({
    names: ["Marlborough", "Eugene", "Vendôme", "Villars"],

    // This won't work
    loudNames: function () {
      return this.get('names').map(makeLoud);
    }.property('names')
  }).create();

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
            

  obj.get('names').pushObject("Berwick");

  // Where's our buddy Berwick?
  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
            

  loudNames: function () {
  /*

  `loudNames` is invalidated when `names` is set to a new array,
  but not when it is mutated (eg when appending new items)

  */
  }.property('names')
            

Solution: `[]`


  function makeLoud(str) { return str.toUpperCase(); }

  var obj = Ember.Object.extend({
    names: ["Marlborough", "Eugene", "Vendôme", "Villars"],

    loudNames: function () {
      return this.get('names').map(makeLoud);
    }.property('names.[]')
  }).create();

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
            

  obj.get('names').pushObject("Berwick");

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS", "BERWICK"]
            

Two issues

  1. Objects with property dependencies
  2. Most of the work is being redone (Marlborough, Eugene, Vendôme, Villars haven't changed)

Dependent arrays with property dependencies

Use `@each.<propertyName>`


  var Person = Ember.Object.extend({
    name: ''
  });

  function p(name) { return Person.create({ name: name }); }
            

  var obj = Ember.Object.extend({
    people: [ p("Marlborough"), p("Eugene"),
              p("Vendôme"), p("Villars")],

    loudNames: function () {
      return this.get('people').mapBy('name').map(makeLoud);
    }.property('people.@each.name')
  }).create();

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
            

  obj.get('people').objectAt(1).set('name', 'Overkirk');

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "OVERKIRK", "VENDÔME", "VILLARS"]
            

Two issues

  1. Objects with property dependencies
  2. Most of the work is being redone (Marlborough, Eugene, Vendôme, Villars haven't changed)

Two issues

  1. Objects with property dependencies
  2. Most of the work is being redone (Marlborough, Eugene, Vendôme, Villars haven't changed)

Solution: `Ember.arrayComputed`

`Ember.computed` includes several array computed macros for common cases


  var map = Ember.computed.map;

  var obj = Ember.Object.extend({
    people: [ p("Marlborough"), p("Eugene"),
              p("Vendôme"), p("Villars")],

    loudNames: map('people.@each.name', function (person) {
      return person.get('name').toUpperCase();
    })
  }).create();

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "EUGENE", "VENDÔME", "VILLARS"]
            

  obj.get('people').objectAt(1).set('name', 'Overkirk');
  obj.get('people').pushObject(Person.create({
    name: 'Berwick'
  }));

  // This time our function was only run for two objects
  // instead of the entire array

  obj.get('loudNames')
  //=> ["MARLBOROUGH", "OVERKIRK", "VENDÔME", "VILLARS", "BERWICK"]
            

Compare


  loudNames: function () {
    return this.get('people').mapBy('name').map(makeLoud);
  }.property('people.@each.name')
            

  loudNames: map('people.@each.name', function (person) {
    return person.get('name').toUpperCase();
  })
            

The function you supply in an array computed property is like the body of a loop.

This is why ember is able to do the work for the new and modified items, rather than looping over all of them.

`Ember.computed.map` is a common enough case that there's a macro in ember for it

There are other macros for common cases

  • sort
  • setDiff
  • filter
  • uniq
  • union
  • intersect

emberjs.com/api

Writing your own array computed property/macro

The API


  Ember.arrayComputed('dependentKey1', /* depKey2, depKey3, …, */ {
    initialize: function (array, changeMeta, instanceMeta) {
      // initialize instanceMeta if you need a scratchpad
      return array;
    },

    addedItem: function (array, item, changeMeta, instanceMeta) {
      // do something to `array` when an item is added
      return array;
    },

    removedItem: function (array, item, changeMeta, instanceMeta) {
      // do something to `array` when an item is removed
      return array;
    }
  });
            

initialize


  {
    initialize: function (array, changeMeta, instanceMeta) {
      return array;
    }
  }
            

`array` is the initial value, an empty array.

`changeMeta` contains metadata about the CP. For `initialize` there is only:

  • `property` the computed property
  • `propertyName` the name of the property on the object (eg `loudNames`)

`instanceMeta` is a scratchpad for your array computed property.

It is unique to the property on the object to which the CP is attached.

addedItem


  {
    addedItem: function (array, item, changeMeta, instanceMeta) {
      return array;
    }
  }
            

`array` is the current value of the array computed property.

`item` is an item that has been added to a dependent array.

`changeMeta` contains metadata about the CP. For `addedItem` this includes:

  • `property` the computed property
  • `propertyName` the name of the property on the object (eg `loudNames`)

`changeMeta contains metadata about the CP. For `addedItem` this includes:

  • `property` the computed property
  • `propertyName` the name of the property on the object (eg `loudNames`)
  • `arrayChanged` the dependent array `item` was added to.
  • `index` the index of `item` in `arrayChanged`.

`instanceMeta` is the same scratchpad passed to `initialize`.

removedItem


  {
    removedItem: function (array, item, changeMeta, instanceMeta) {
      return array;
    }
  }
            
Just like `addedItem`, except it's called when an item is removed from a dependent array.

What about modified items?


  var obj = Ember.Object.extend({
    myArrayCP: Ember.arrayComputed('upstream.@each.property', { /* … */ })
  });

  obj.get('upstream').objectAt(2).set('property', 'newValue');
            

Modified items are treated as a remove and immediate re-add.

removedItem


  {
    removedItem: function (array, item, changeMeta, instanceMeta) {
      return array;
    }
  }
            

During removes resulting from modifications, `changedMeta` also contains `previousValues`, a POJO with the old values.

This can help you eg both remove and add an item from a sorted array in O(lg n).

Ember.computed.map


  function map(dependentKey, callback) {
    var options = {
      addedItem: function(array, item, changeMeta, instanceMeta) {
        var mapped = callback.call(this, item);
        array.insertAt(changeMeta.index, mapped);
        return array;
      },

      removedItem: function(array, item, changeMeta, instanceMeta) {
        array.removeAt(changeMeta.index, 1);
        return array;
      }
    };

    return arrayComputed(dependentKey, options);
  };
          

More advanced issues

  • Non-array dependencies
  • Working with item controllers (and proxies generally)
  • Mixing one-at-a-time and complete-invalidation semantics

More advanced issues

  • Non-array dependencies
  • Working with item controllers (and proxies generally)
  • Mixing one-at-a-time and complete-invalidation semantics

            arrayComputed('someArray', 'someString', {
              addedItem:    function () { /* … */ },
              removedItem:  function () { /* … */ }
            });
            

When a non-array dependency is changed, the array computed property is completely invalidated.

Other than managing array observers, and providing the one-at-a-time callbacks, array computed properties behave like any other computed property.

More advanced issues

  • Non-array dependencies
  • Working with item controllers (and proxies generally)
  • Mixing one-at-a-time and complete-invalidation semantics

  var App.PeopleController = Ember.ArrayController.extend({
    itemController: 'person',
    myArrayCP: Ember.arrayComputed('???', { /* … */ })
  });
            

  var App.PeopleController = Ember.ArrayController.extend({
    itemController: 'person',
    // If we refer to `model` directly, our items won't be using
    // the person item controller
    myArrayCP: Ember.arrayComputed('model', { /* … */ })
  });
            

  var App.PeopleController = Ember.ArrayController.extend({
    itemController: 'person',
    
    self: function () {
      return this;
    }.property(),

    myArrayCP: Ember.arrayComputed('self', { /* … */ })
  });
            

  var App.PeopleController = Ember.ArrayController.extend({
    itemController: 'person',
    myArrayCP: Ember.arrayComputed('@this', { /* … */ })
  });
            

More advanced issues

  • Non-array dependencies
  • Working with item controllers (and proxies generally)
  • Mixing one-at-a-time and complete-invalidation semantics

  Ember.Object.extend({
    flags: ['includeThis', 'modifyThat'],
    upstreamArray: [],
    myArrayCP: Ember.arrayComputed('upstreamArray', '???', { /* … */ })
  });
            

Other than managing array observers, and providing the one-at-a-time callbacks, array computed properties behave like any other computed property.


  Ember.Object.extend({
    flags: ['includeThis', 'modifyThat'],
    upstreamArray: [],
    myArrayCP: Ember.arrayComputed('upstreamArray', 'flags.[]', { /* … */ })
  });
            

When `flags` is mutated, the array computed property is completely invalidated.

When `upstreamArray` is mutated, the one-at-a-time callbacks (`addedItem`, `removedItem`) are invoked.

You always want at least one dependent key with one-at-a-time semantics.

If you don't have this, just use a regular CP.

Array computed properties are like a live-updating `reduce` where the produced value happens to be an array.

Can we have the same one-at-a-time semantics for non-array values?

No

No Yes

Ember.reduceComputed


  Ember.reduceComputed('dependentKey1', /* depKey2, depKey3, …, */ {
    initialValue: function () {
      return Ember.Set.create();
    },

    initialize: initFn,
    addedItem: addedItemFn,
    removedItem: removedItemFn
  });
          

  Ember.reduceComputed('dependentKey1', /* depKey2, depKey3, …, */ {
    // simple values don't need a function
    initialValue: 0,  // or "string", &c.

    initialize: initFn,
    addedItem: addedItemFn,
    removedItem: removedItemFn
  });
          

One last example

uniq


  function uniq() {
    var args = Array.prototype.slice.call(arguments);
    args.push({
      initialValue: function () { /* … */ },

      initialize:   function () { /* … */ },
      addedItem:    function () { /* … */ },
      removedItem:  function () { /* … */ },
    });
    return reduceComputed.apply(null, args);
  };
            

  initialValue: function () {
    return Ember.Set.create();
  },

  initialize: function(set, changeMeta, instanceMeta) {
    instanceMeta.itemCounts = {};
  }
            

  addedItem: function(set, item, changeMeta, instanceMeta) {
    var guid = guidFor(item);

    if (!instanceMeta.itemCounts[guid]) {
      instanceMeta.itemCounts[guid] = 1;
      set.addObject(item);
    } else {
      ++instanceMeta.itemCounts[guid];
    }
    return set;
  }
            

  removedItem: function(set, item, changeMeta, instanceMeta) {
    var guid = guidFor(item),
        itemCounts = instanceMeta.itemCounts;

    if (--itemCounts[guid] === 0) {
      set.removeObject(item);
    }
    return set;
  }
            

Thanks!

David J. Hamilton

@hjdivad

github.com/hjdivad