Deprecation Guide for Array Observers
Array observers are a special type of observer that can be used to synchronously
react to changes in an EmberArray
. In general, to refactor away from them, these
reactions need to be converted from eager, synchronous reactions to lazy
reactions that occur when the array in question is used or accessed.
For example, let's say that we had a class that wrapped an EmberArray
and
converted its contents into strings by calling toString()
on them. This class
could be implemented using array observers like so:
class ToStringArray {
constructor(innerArray) {
this._inner = innerArray;
this._content = innerArray.map((value) => value.toString());
innerArray.addArrayObserver(this, {
willChange: '_innerWillChange',
didChange: '_innerDidChange',
});
}
// no-op
_innerWillChange() {}
_innerDidChange(innerArray, changeStart, removeCount, addCount) {
if (removeCount) {
// if items were removed, remove them
this._content.removeAt(changeStart, removeCount);
} else {
// else, find the new items, convert them, and add them to the array
let newItems = innerArray.slice(changeStart, addCount);
this._content.replace(
changeStart,
0,
newItems.map((value) => value.toString())
);
}
// Let observers/computeds know that the value has changed
notifyPropertyChange(this, '[]');
}
objectAt(index) {
return this._content.objectAt(index);
}
}
To move away from array observers, we could instead convert the behavior so that
the objects are converted into strings when the array is accessed using
objectAt
. We can call this behavior lazy wrapping, as opposed to eager
wrapping which happens when the item is added to the array. We can do this using
the using the @cached
decorator from tracked-toolbox.
import { cached } from 'tracked-toolbox';
class ToStringArray {
constructor(innerArray) {
this._inner = innerArray;
}
@cached
get _content() {
return this._inner.map((value) => value.toString());
}
objectAt(index) {
return this._content.objectAt(index);
}
}
This can also be accomplished with native Proxy.
Your users can interact with the array using standard array syntax
instead of objectAt
:
class ToStringArrayHandler {
constructor(innerArray) {
this._inner = innerArray;
}
@cached
get _content() {
return this._inner.map((value) => value.toString());
}
get(target, prop) {
return this._content.objectAt(prop);
}
}
function createToStringArray(innerArray) {
return new Proxy([], new ToStringArrayHandler(innerArray));
}
This solution will work with autotracking in general, since users who access the
array via objectAt
will be accessing the tracked property. However, it will
not integrate with computed property dependencies. If that is needed, then you
can instead extend Ember's built-in ArrayProxy
class, which handles forwarding
events and dependencies.
import ArrayProxy from '@ember/array/proxy';
import { cached } from 'tracked-toolbox';
class ToStringArray extends ArrayProxy {
@cached
get _content() {
return this.content.map((value) => value.toString());
}
objectAtContent(index) {
return this._content.objectAt(index);
}
}
Converting code that watches arrays for changes
Array observers and change events can be used to watch arrays and react to
changes in other ways as well. For instance, you may have a component like
ember-collection
that used array observers to trigger a rerender and
rearrange its own representation of the array. A simplified version of this
logic looks like the following:
export default Component.extend({
layout: layout,
init() {
this._cells = A();
},
_needsRevalidate() {
if (this.isDestroyed || this.isDestroying) {
return;
}
this.rerender();
},
didReceiveAttrs() {
this._super();
this.updateItems();
},
updateItems() {
var rawItems = this.get('items');
if (this._rawItems !== rawItems) {
if (this._items && this._items.removeArrayObserver) {
this._items.removeArrayObserver(this, {
willChange: noop,
didChange: '_needsRevalidate',
});
}
this._rawItems = rawItems;
var items = A(rawItems);
this.set('_items', items);
if (items && items.addArrayObserver) {
items.addArrayObserver(this, {
willChange: noop,
didChange: '_needsRevalidate',
});
}
}
},
willRender() {
this.updateCells();
},
updateCells() {
// ...
},
actions: {
scrollChange(scrollLeft, scrollTop) {
// ...
if (scrollLeft !== this._scrollLeft || scrollTop !== this._scrollTop) {
set(this, '_scrollLeft', scrollLeft);
set(this, '_scrollTop', scrollTop);
this._needsRevalidate();
}
},
clientSizeChange(clientWidth, clientHeight) {
if (
this._clientWidth !== clientWidth ||
this._clientHeight !== clientHeight
) {
set(this, '_clientWidth', clientWidth);
set(this, '_clientHeight', clientHeight);
this._needsRevalidate();
}
},
},
});
We can refactor this to update the cells when they are accessed. We'll do this
by calling updateCells
within a computed property that depends on the items
array:
export default Component.extend({
layout: layout,
init() {
this._cells = A();
},
cells: computed('items.[]', function() {
this.updateCells();
return this._cells;
}),
updateCells() {
// ...
},
actions: {
scrollChange(scrollLeft, scrollTop) {
// ...
if (scrollLeft !== this._scrollLeft ||
scrollTop !== this._scrollTop) {
set(this, '_scrollLeft', scrollLeft);
set(this, '_scrollTop', scrollTop);
this.notifyPropertyChange('cells');
}
},
clientSizeChange(clientWidth, clientHeight) {
if (this._clientWidth !== clientWidth ||
this._clientHeight !== clientHeight) {
set(this, '_clientWidth', clientWidth);
set(this, '_clientHeight', clientHeight);
this.notifyPropertyChange('cells');
}
}
}
});
Mutating untracked local state like this is generally ok as long as the local state is only a cached representation of the value that the computed or getter is deriving in general. It allows you to do things like compare the previous state to the current state during the update, and cache portions of the computation so that you do not need to redo all of it.
It is also possible that you have some code that must run whenever the array
has changed, and must run eagerly. For instance, the array fragment from
ember-data-model-fragments
has some logic for signalling to the parent record
that it has changed, which looks like this (simplified):
const StatefulArray = ArrayProxy.extend(Copyable, {
content: computed(function () {
return A();
}),
// ...
arrayContentDidChange() {
this._super(...arguments);
let record = get(this, 'owner');
let key = get(this, 'name');
// Any change to the size of the fragment array means a potential state change
if (get(this, 'hasDirtyAttributes')) {
fragmentDidDirty(record, key, this);
} else {
fragmentDidReset(record, key);
}
},
});
Ideally the dirty state would be converted into derived state that could read
the array it depended on. If that's not an option or would require
major refactors, it is also possible to override the mutator method of the array
and trigger the change when it is called. In EmberArray's, the primary mutator
method is the replace()
method.
const StatefulArray = ArrayProxy.extend(Copyable, {
content: computed(function () {
return A();
}),
// ...
replace() {
this._super(...arguments);
let record = get(this, 'owner');
let key = get(this, 'name');
// Any change to the size of the fragment array means a potential state change
if (get(this, 'hasDirtyAttributes')) {
fragmentDidDirty(record, key, this);
} else {
fragmentDidReset(record, key);
}
},
});
Note that this method will work for arrays and array proxies that are mutated directly, but will not work for array proxies that wrap other arrays and watch changes on them. In those cases, the recommendation is to refactor such that:
- Changes are always intercepted by the proxy, and can call the code synchronously when they occur.
- The change logic is added by intercepting changes on the original array, so it will occur whenever the array changes.
- The API that must be called synchronously is instead driven by derived state.
For instance, in the example above, the record's dirty state could be driven
by the various child fragments it contains. The dirty state could be updated whenever the user
accesses it, rather than by sending events such as
didDirty
anddidReset
.
Converting code that uses the willChange
functionality
In general, it is no longer possible to react to an array change before it
occurs except by overriding the mutation methods on the array itself. You can do
this by replacing them and calling your logic before calling super
.
const ArrayWithWillChange = EmberObject.extend(MutableArray, {
replace() {
// Your logic here
this._super(...arguments);
},
});
In cases where this is not possible, you can instead convert to derived state, and cache the previous value of the array to compare it the next time the state is accessed.