Stage: Accepted Start Date: 2019-12-22 Release Date: Unreleased Release Versions: ember-source: vX.Y.Z ember-data: vX.Y.Z Relevant Team(s): Ember.js RFC PR: https://github.com/emberjs/rfcs/pull/566

@cached

Summary

Add a @cached decorator for memoizing the result of a getter based on autotracking. In the following example, fullName would only recalculate if firstName or lastName is updated.

class Person {
  @tracked firstName = 'Jen';
  @tracked lastName = 'Weber';

  @cached
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

Motivation

One of the major differences between computed properties and tracked properties with autotracking in Octane is that native, autotracked getters do not automatically cache their values, where computed properties were cached by default. This was an intentional design choice, as the memoization logic for computed properties was actually more costly, on average, than rerunning the getter in the first place. This was especially true given that computed properties would usually only ever be calculated and used once or twice per render before being updated.

However, there are absolutely cases where getters are expensive, and their values are used repeatedly, so memoization would be very helpful. Strategic, opt-in memoization is a useful tool that would help Ember developers optimize their apps when relevant, without adding extra overhead unless necessary.

Detailed design

The @cached decorator will be exported from @glimmer/tracking, alongside @tracked. It can be used on native getters to memoize their return values based on the tracked state they consume while being calculated.

import { tracked, cached } from '@glimmer/tracking';

class Person {
  @tracked firstName = 'Jen';
  @tracked lastName = 'Weber';

  @cached
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

In this example, the fullName getter will be memoized whenever it is called, and will only be recalculated the next time the firstName or lastName properties are set. This would apply to any autotracking tags consumed while calculating the getter, so changes to EmberArrays and other tracked primitives, for instance, would also cause invalidations.

If used on a non-getter, @cached will throw an error in DEBUG modes. Properties can also include a setter, but it won't affect the memoization of the getter (except by potentially setting the state that was tracked in the first place).

Invalidation

@cached will propagate invalidations whenever any of the properties it is entangled with are invalidated, causing any downstream state that has consumed to be invalidated at the same time.

This is necessary in order to have the memoized value be pulled on again in general. There is no way to, for instance, only propagate changes if the memoized value has changed, since we must calculate the memoized value first to know what it's new value is, and we must propagate the change in order to ensure that it will be recalculated.

Cycles

Cycles will not be allowed with @cached. The cache will only be activated after the getter has fully calculated, so any cycles will cause infinite recursion (and eventually, stack overflow), just like un-memoized getters. If a cycle is detected in DEBUG mode, it will throw an error.

How we teach this

@cached is not an essential part of the reactivity model, so it shouldn't be covered during the main component/reactivity guide flow. Instead, it should be covered in the intermediate/in-depth guides, and in any performance related guides. We should also note that the decorator should not be sprinkled around carelessly or defensively - instead, it should only be used when the computation being cached is confirmed to be computationally expensive.

API Docs

The @cached decorator can be used on getters in order to cache the return value of the getter. This is useful when a getter is expensive and used very often. For instance, in this guest list class, we have the sortedGuests getter that sorts the guests alphabetically:

import { tracked } from '@glimmer/tracking';

class GuestList {
  @tracked guests = ['Zoey', 'Tomster'];

  get sortedGuests() {
    return this.guests.slice().sort()
  }
}

Every time sortedGuests is accessed, a new array will be created and sorted, because JavaScript getters do not cache by default. When the guest list is small, like the one in the example, this is not a problem. However, if the guest list were to grow very large, it would mean that we would be doing a large amount of work each time we accessed sortedGetters. With @cached, we can cache the value instead:

import { tracked, cached } from '@glimmer/tracking';

class GuestList {
  @tracked guests = ['Zoey', 'Tomster'];

  @cached
  get sortedGuests() {
    return this.guests.slice().sort()
  }
}

Now the sortedGuests getter will be cached based on autotracking. It will only rerun and create a new sorted array when the guests tracked property is updated.

In general, you should avoid using @cached unless you have confirmed that the getter you are decorating is computationally expensive. @cached adds a small amount of overhead to the getter, making it more expensive. While this overhead is small, if @cached is overused it can add up to a large impact overall in your app. Many getters and tracked properties are only accessed once, rendered, and then never rerendered, so adding @cached when it is unnecessary can negatively impact performance.

Drawbacks

  • Adds extra complexity when programming (whether or not a value should be memoized is now a decision that has to be made). In general, we should make sure this is not an issue by recommending that memoization idiomatically not be used unless it is absolutely necessary.

  • Adds extra overhead for each memoized getter. This again should be addressed by teaching that it should be avoided when possible. The cost tradeoff should be noted in documentation in particular to emphasize this, and discourage overuse.

  • @cached may rerun even if the values themselves have not changed, since tracked properties will always invalidate even if their underlying value did not change. Unfortunately, this is not really something that @cached can circumvent, since there's no way to tell if a value has actually changed, or to know which values are being accessed when the memoized value is accessed until the getter is run.

    Instead, we should be sure that the rules of property invalidation are clear, and in performance sensitive situations we recommend diff checking when assigning the property:

    if (newValue !== this.trackedProp) {
      this.trackedProp = newValue;
    }
    

Alternatives

  • @memo or @memoized could be alternative names. Initially @memo was used, but it was decided to switch to @cached.

  • @cached could receive arguments of the keys to memoize based on. This would bring us back to the ergonomics of computed properties, however, and would not be ideal. It also would bring no actual benefits, except being able to exclude certain values from recalculation.