Start Date Release Date Release Versions PR link Tracking Link Stage Teams
12/5/2018 9/19/2019
  • ember-source: v3.13.0
Recommended
  • Framework

Tracked Properties

Summary

Tracked properties introduce a simpler and more ergonomic system for tracking state change in Ember applications. By taking advantage of new JavaScript features, tracked properties allow Ember to reduce its API surface area while producing code that is both more intuitive and less error-prone.

This simple example shows a Person class with three tracked properties:

export default class Person {
  @tracked firstName = 'Chad';
  @tracked lastName = 'Hietala';

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

A Note on Decorator Support

This RFC proposes a decorator version of tracked properties, and uses this decorator version in most examples, on the assumption that the [Decorators RFC] (https://github.com/emberjs/rfcs/pull/408) will be accepted and implemented before this RFC. If the Decorators RFC is not accepted, or cannot be implemented due to other criteria not being met (such as decorators remaining at stage 2), then only the classic class syntax for tracked properties will be implemented.

Terminology

Because of the occasional overlap in terminology when discussing similar features, this document uses the following language consistently:

  • A getter is an ES5 JavaScript feature that executes a function to determine the value of a property. The function is executed every time the property is accessed.
  • A computed property is a property on an Ember object whose value is lazily produced by executing a function. That value is nearly always cached until one of computed property's dependencies changes.
  • A tracked property refers to any class field that has been instrumented with @tracked. Unlike computed properties, tracked properties are never getters or setters.
  • The classic programming model refers to the traditional Ember programming model. It includes classic classes, computed properties, event listeners, observers, property notifications, and classic components, and more generally refers to features that will not be central to Ember Octane. Concepts like routes, controllers, and services belong to both the Octane programming model and the classic programming model.
  • Native classes are classes defined using the Javascript class keyword.
  • Classic classes are classes defined by subclassing from EmberObject using the static extend method.

Motivation

Tracked properties are designed to be simpler to learn, simpler to write, and simpler to maintain than today's computed properties. In addition to clearer code, tracked properties eliminate the most common sources of bugs and mental model confusion in computed properties today, and reduce memory overhead by not caching by default.

Leverage Existing JavaScript Knowledge

Ember's computed properties provide functionality that overlaps with native JavaScript getters and setters. Because native getters don't provide Ember with the information it needs to track changes, it's not possible to use them reliably in templates or in other computed properties.

New learners have to "unlearn" native getters, replacing them with Ember's computed property system. Unfortunately, this knowledge is not portable to other applications that don't use Ember that developers may work on in the future, and while this problem may be lessened by adopting native classes and decorators, it still requires users learn Ember's notification system and its quirks.

Tracked properties are as thin a layer as possible on top of native JavaScript. Tracked properties look like normal properties because they are normal properties.

Because there is no special syntax for retrieving a tracked property, any JavaScript syntax that feels like it should work does work:

// Dot notation
const fullName = person.fullName;
// Destructuring
const { fullName } = person;
// Bracket notation for computed property names
const fullName = person['fullName'];

Similarly, syntax for changing properties works just as well:

// Simple assignment
this.firstName = 'Yehuda';
// Addition assignment (+=)
this.lastName += 'Katz';
// Increment operator
this.age++;

This compares favorably with APIs from other libraries, which becomes more verbose than necessary when JavaScript syntax isn't available:

this.setState({
  age: this.state.age + 1,
});
this.setState({
  lastName: this.state.lastName + "Katz";
})

Avoiding Dependency Hell

Currently, Ember requires developers to manually enumerate a computed property's dependent keys: the list of other properties that this computed property depends on. Whenever one of the listed properties changes, the computed property's cache is cleared and any listeners are notified that the computed property has changed.

In this example, 'firstName' and 'lastName' are the dependent keys of the fullName computed property:

import EmberObject, { computed } from '@ember/object';

const Person = EmberObject.extend({
  fullName: computed('firstName', 'lastName', function() {
    return `${this.firstName} ${this.lastName}`;
  }),
});

While this system typically works well, it comes with its share of drawbacks.

First, it's extra work to have to type every property twice: once as a string as a dependent key, and again as a property lookup inside the function. While explicit APIs can often lead to clearer code, this verbosity has the potential to complicate the implementation without improving developer intent at all. People understand intuitively that they are typing out dependent keys to help Ember, not other programmers.

It's also not clear what syntax goes inside the dependent key string. In this simple example it's a property name, but nested dependencies become a property path, like 'person.firstName'. (Good luck writing a computed property that depends on a property with a period in the name.)

You might form the mental model that a JavaScript expression goes inside the string—until you encounter the {firstName,lastName} expansion syntax or the magic @each syntax for array dependencies.

The truth is that dependent key strings are made up of an unintuitive, unfamiliar microsyntax that you just have to memorize if you want to use Ember well.

Lastly, it's easy for dependent keys to fall out of sync with the implementation, leading to difficult-to-detect, difficult-to-troubleshoot bugs.

For example, imagine a new member on our team is assigned a bug where a user's middle name is not appearing in their profile. Our intrepid developer finds the problem, and updates fullName to include the middle name:

import EmberObject, { computed } from '@ember/object';

const Person = EmberObject.extend({
  fullName: computed('firstName', 'lastName', function() {
    return `${this.firstName} ${this.middleName} ${this.lastName}`;
  }),
});

They test their change and it seems to work. Unfortunately, they've just introduced a subtle bug. If the user's middleName were to change, fullName wouldn't update! Maybe this will get caught in a code review, given how simple the computed property is, but noticing missing dependencies is a challenge even for experienced Ember developers when the computed property gets more complicated.

Tracked properties have a feature called autotrack, where dependencies are automatically detected as they are used. This means that as long as all properties that are dependencies are marked as tracked, they will automatically be detected:

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

class Person {
  @tracked firstName = 'Tom';
  @tracked lastName = 'Dale';

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

Note that getters and setters do not need to be marked as tracked, only the properties that they access need to. This also allows us to opt out of tracking entirely, like if we know for instance that a given property is constant and will never change. In general, the idea is that mutable, watchable properties should be marked as tracked, and immutable or unwatched properties should not be.

Reducing Memory Consumption

By default, computed properties cache their values. This is great when a computed property has to perform expensive work to produce its value, and that value gets used over and over again.

But checking, populating, and invalidating this cache comes with its own overhead. Modern JavaScript VMs can produce highly optimized code, and in many cases the overhead of caching is greater than the cost of simply recomputing the value.

Worse, cached computed property values cannot be freed by the garbage collector until the entire object is freed. Many computed properties are accessed only once, but because they cache by default, they take up valuable space on the heap for no benefit.

For example, imagine this component that checks whether the files property is supported in input elements:

import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  inputElement: computed(function() {
    return document.createElement('input');
  }),

  supportsFiles: computed('inputElement', function() {
    return 'files' in this.inputElement;
  }),

  didInsertElement() {
    if (this.supportsFiles) {
      // do something
    } else {
      // do something else
    }
  },
});

This component would create and retain an HTMLInputElement DOM node for the lifetime of the component, even though all we really want to cache is the Boolean value of whether the browser supports the files attribute.

Particularly on inexpensive mobile devices, where RAM is limited and often slow, we should be more conservative about our memory consumption. Tracked properties switch from an opt-out caching model to opt-in, allowing developers to err on the side of reduced memory usage, but easily enabling caching (a.k.a. memoization) if a property shows up as a bottleneck during profiling.

Prior Art

Tracked properties were first implemented in Glimmer.js, and were recently polyfilled with clever usage of notifyPropertyChange by the sparkles-components addon. These initial implementations inform the design in this RFC, but differ from it in some key ways. For instance, both Sparkles's and early versions of Glimmer's @tracked did not have an autotracking stack, and instead relied on explicit dependency keys. After benchmarking showed that autotracking was a viable strategy, the API for @tracked was updated to what is proposed here.

Detailed Design

This RFC proposes adding the tracked decorator function, used to mark class fields as tracked:

const tracked: PropertyDecorator;

This new function will be exported from @glimmer/tracking. Revisiting our example from earlier, @tracked can be used on native class fields and getters/setters:

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

class Person {
  @tracked firstName = 'Tom';
  @tracked lastName = 'Dale';

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

Getting Tracked Properties

Tracked properties can be accessed using standard Javascript syntax. From the user's point of view, there is nothing special about them. This should continue to work in the future, even if new methods are added for accessing properties, because tracked properties use native getters under the hood.

let person = new Person();

// Dot notation
const fullName = person.fullName;
// Destructuring
const { fullName } = person;
// Bracket notation for computed property names
const fullName = person['fullName'];

Setting Tracked Properties

Tracked properties can be set using standard Javascript syntax. They use native setters under the hood, meaning that there is no need for using a setter method like set.

let person = new Person();

// Simple assignment
person.firstName = 'Jen';
// Addition assignment (+=)
person.lastName += 'Weber';
// Increment operator
person.age++;

Autotracking

Tracked properties do not need to specify their dependencies. Under the hood, this works by utilizing an autotrack stack. This stack is a bit of global state which tracked properties can access. As tracked properties are accessed, they push themselves onto the stack, and once they have finished running, the stack contains the full list of all the tracked properties that were accessed while it was running.

In our first example, with the Person class, we can see this in action:

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

class Person {
  @tracked firstName = 'Tom';
  @tracked lastName = 'Dale';

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

When we create a new instance of Person, the tracking system has no knowledge of the connection between fullName, firstName, and lastName. Now, let's say we go to render this person's name in a component's template:

{{this.person.fullName}}

When Glimmer accesses the fullName property on person, it creates an autotrack stack frame. As we computed fullName, any values that are decorated with @tracked push themselves into this stack frame. Because getters and setters are pure functions, they will ultimately end up accessing some tracked properties - in this case, the fullName getter accesses the firstName and lastName properties, and they push themselves onto the stack frame.

In this way, Glimmer will know about all properties that were accessed when calculating any bound value in templates.

NOTE: This does not invalidate a cache like in computed properties. Internally, Glimmer checks to see if a value has updated before calling the getter. If it hasn't, then Glimmer does not rerender the related section of the DOM. This is effectively an automatic shouldComponentUpdate (at least the most common usage) from React.

Manual Invalidation

In user code, the idea that all mutable properties should be marked as tracked and that all other properties are effectively immutable works well in isolation. However, there are cases where users will want to work with code they do not control, such as external library code.

Consider the following example. We have a simple-timer library that we've imported from NPM, and we're trying to wrap it with a TimerComponent that uses it to keep track of how much time has passed:

// simple-timer/index.js
export default class Timer {
  seconds = 0;
  minutes = 0;
  hours = 0;

  listeners = [];

  constructor() {
    setInterval(() => {
      this.seconds++;
      this.minutes = Math.floor(this.seconds / 60);
      this.hours = Math.floor(this.minutes / 60);
      this.notifyTick();
    }, 1000);
  }

  notifyTick() {
    for (let listener of this.listeners) {
      listener(this.seconds);
    }
  }

  onTick(listener) {
    this.listeners.push(listener);
  }
}
import Timer from 'simple-timer';
import Component, { tracked } from '@glimmer/tracking';

export default class TimerComponent extends Component {
  @tracked timer = new Timer();

  get currentSeconds() {
    return this.timer.seconds;
  }

  get currentMinutes() {
    return this.timer.minutes;
  }
}

Even though we've marked the timer property as tracked, the timer.seconds property is untracked, and it is the field that is updated. We can solve this problem by using the timer library's onTick event handler to re-set the field, invalidating it:

export default class TimerComponent extends Component {
  @tracked timer = new Timer();

  constructor() {
    this.timer.onTick(() => {
      // invalidate the timer field.
      this.timer = this.timer;
    });
  }

  get currentSeconds() {
    return this.timer.seconds;
  }

  get currentMinutes() {
    return this.timer.minutes;
  }
}

Interop with the Classic Programming Model

Tracked properties represent a paradigm shift. They are a completely new system, fully independent of the classic programming model and based on modern Javascript features and design, and they will be the default change tracking system in Ember Octane.

However, existing apps, libraries, and addons will be using the classic programming model for some time, and experience tells us that these sort of transitions to new features take a while to settle in the community. To ease this process and enable gradual adoption, tracked properties will be able to interoperate with the most commonly used features of the classic model:

  • Classic classes
  • Computed properties
  • get/set and property notifications
  • Observers

Classic Classes

The tracked decorator function will be usable in classic classes, similar to computed:

import EmberObject from '@ember/object';
import { tracked } from '@glimmer/tracking';

const Person = EmberObject.extend({
  firstName: tracked({ value: 'Tom' }),
  lastName: tracked({ value: 'Dale' }),

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

This form will not be allowed on native classes, and will hard error if it is attempted. Additionally, default values will be defined on the prototype to maintain consistency with the classic object model.

This will allow existing libraries to transition incrementally, and add tracked support minimally where necessary. This also brings the benefits of tracked to classic classes, including the ability to drop usage of set:

// before
let person = Person.create();
person.set('firstName', 'Stefan');
person.set('lastName', 'Penner');

// after
let person = Person.create();
person.firstName = 'Stefan';
person.lastName = 'Penner';

Ember's set function is nowhere to be seen!

Computed Properties

Computed properties will interoperate with tracked properties in both directions:

  • Accessing a computed property from a tracked property will add the computed property to its list of depedencies. Whenever the computed property is invalidated (i.e. because it or one of its dependencies is updated), the tracked property will be invalidated as well.
  import { tracked } from '@glimmer/tracking';
  import { set } from '@ember/object';
  import { alias } from '@ember/object/computed';

  class Person {
    @tracked firstName;
    @tracked lastName;

    @alias('title') prefix;

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

  let person = new Person();

  person.firstName = 'Tom';
  person.lastName = 'Dale';

  set(person, 'title', 'Mr.');

  person.fullName; // 'Mr. Tom Dale'
  • Accessing a tracked property from a computed property will also automatically add the tracked property to the list of its dependencies. In this way, users will be able to gradually add tracked properties and simultaneously reap the benefits of not having to use set with computeds, and not having to specify dependent keys.
  import { computed } from '@ember/object';
  import { tracked } from '@glimmer/tracking';

  class Person {
    firstName;
    lastName;

    @tracked middleName;

    @computed('firstName', 'lastName')
    get fullName() {
      return `${this.firstName} ${this.middleName} ${this.lastName}`;
    }
  }

  let person = new Person();

  set(person, 'firstName', 'Tom');
  set(person, 'lastName', 'Dale');

  person.middleName = 'Tomster';

  person.fullName; // 'Tom Tomster Dale'

It will still be required to use set when updating computed properties and their dependencies. In the future, this restriction could possibly be relaxed.

get and set

It is common in the classic model to set and consume plain object properties which are not computed properties, or in any other way special. Ember's get and set functions historically allowed this by giving us the ability to intercept all property changes and watch for mutations.

This presents a problem for tracked properties, particularly because of the recent change in Ember to enable native Javascript getters to replace get. This change means that we have no way to intercept get, and consequently no way for tracked properties to know whether or not a plain property will later be updated with set.

To demonstrate this case, consider the following service and component:

const Config = Service.extend({
  polling: {
    shouldPoll: false,
    pollInterval: -1,
  },

  init() {
    this._super(...arguments);

    fetch('config/api/url')
      .then(r => r.json())
      .then(polling => set(this, 'polling', polling));
  },
});
class SomeComponent extends Component {
  @service config;

  get pollInterval() {
    let { shouldPoll, pollInterval } = this.config.polling;

    return shouldPoll ? pollInterval : -1;
  }
}
{{this.pollInterval}}

Let's walk through the flow here:

  1. The SomeComponent component is rendered for the first time, instantiating the Config service (assuming this the first time it has ever been accessed). The service's init hook kicks off an async request to get the configuration from a remote URl.
  2. The pollInterval property first accesses the service injection when rendered, which is a computed property. The property is detected and added to the tracked stack.
  3. We then access the plain, undecorated polling object. Because it is is not tracked and not a computed property, tracked does not know that it could update in the future.
  4. Sometime later, the async request returns with the configuration object. We set it on the service, but because our tracked getter did not know this property would update, it does not invalidate.

In order to prevent this from happening, user's will have to use get when accessing any values which may be set with set, and are not computed properties.

class SomeComponent extends Component {
  @service config;

  get pollInterval() {
    let shouldPoll = get(this, 'config.polling.shouldPoll');
    let pollInterval = get(this, 'config.polling.pollInterval');

    return shouldPoll ? pollInterval : -1;
  }
}

The reverse, however, is not true - computed properties will be able to add tracked properties, and listen to dependencies explicitly. In some cases, this may be preferable, though undecorated getters should be the conventional standard with the long term goal of removing all explicit dependencies and computed decorations.

Observers

While Ember's observer system has been minimized in recent years, it is still supported in Ember 3 and used occasionally throughout the ecosystem. Observers use a fundamentally different system for tracking changes than tracked properties, but this does not mean that it is impossible for the two systems to interoperate, and it theory it shouldn't require much effort to maintain such interoperation or regress performance in any meaningful way.

As such, tracked properties will be made to interoperate with observers so that whenever a tracked property is set using any valid syntax, observers watching that key will be fired:

import { tracked } from '@glimmer/tracking';
import { addObserver } from '@ember/object/observers';

class Person {
  constructor() {
    addObserver('firstName', () => {
      console.log('firstName changed!');
    });
  }

  @tracked firstName;
}

If in the implementation of this RFC it becomes apparent that there are major caveats to supporting interop with observers, a followup RFC will be made to address those caveats and make a decision on whether or not to support observers with those additional constraints.

Does this mean I still have to use get and set?

Yes. As mentioned above, interoperating with legacy code will require using get and set to be fully safe. However, even in greenfield applications which do not need to interoperate with legacy addons or code, there will still be use cases which are not covered by tracked properties. These use cases are roughly the same as those that come with native ES Getters:

  1. Objects that implement unknownProperty and setUnknownProperty
  2. Ember proxies, which use unknownProperty and setUnknownProperty
  3. In general, cases where change tracking should be dynamic, where the keys that are being tracked are not known in advance and cannot be declared using decorators.

get and set will continue to work (as defined in this RFC) and will be necessary in many applications for the forseeable future. How long exactly is an open question addressed below in the unresolved questions section.

How we teach this

There are three different aspects of tracked properties which need to be considered for the learning story:

  1. General usage. Which properties should I mark as tracked? How do I consume them? How do I trigger changes?
  2. Interop with classic systems. How do I safely consume tracked properties from classic classes and computeds? How do I safely consume classic APIs from tracked properties?
  3. Interop with non-Ember systems. How do I tell my app that something has changed in MobX objects, RxJS objects, Redux, etc.

General Usage

The mental model with tracked properties is that anything mutable that is public should be tracked. If a value will ever change, and it will or could be watched externally, it should have the @tracked decorator attached to it.

After that, usage should be "Just Javascript". You can safely access values using any syntax you like, including desctructuring, and you can update values using standard assignments.

// Dot notation
const fullName = person.fullName;
// Destructuring
const { fullName } = person;
// Bracket notation for computed property names
const fullName = person['fullName'];

// Simple assignment
this.firstName = 'Yehuda';
// Addition assignment (+=)
this.lastName += 'Katz';
// Increment operator
this.age++;

Triggering Updates on Complex Objects

There may be cases where users want to update values in complex, untracked objects such as arrays or POJOs. @tracked will only be usable with class syntax at first, and while it may make sense to formalize these objects into tracked classes in some cases, this will not always be the case.

To do this, users can re-set a tracked value directly after its inner values have been updated.

class SomeComponent extends Component {
  @tracked items = [];

  @action
  pushItem(item) {
    let { items } = this;

    items.push(item);

    this.items = items;
  }
}

This may seem a bit strange at first, but it allows users to mentally scope off a tree of objects. They manipulate internals as they see fit, and the only operation they need to do to update state is set the nearest tracked property.

Interop with Classic Systems

There are two cases that we need to consider when teaching interoperability:

  1. Accessing non-tracked properties and computeds from an autotrack context
  2. Accessing tracked properties from a computed context

In the first case, the general rule of thumb is to use get if you want to be 100% safe. In cases where you are certain that the values you are accessing are tracked, computeds, or immutable, you can safely use standard access syntax.

In the second case, no additional changes need to be made when using tracked properties. They can be accessed as normal, and will be automatically added to the computed's dependencies. There is no need to use get, and you can use standard assignments when updating them.

Interop with Non-Ember Systems

The strategy for trickier updates on complex objects by retriggering their setters should cover most integration use cases. We should add a guide which specifically demonstrates their usage by wrapping a common, simple external library such as moment.js. This will demonstrate its usage concretely, and establish best practices.

Drawbacks

Like any technical design, tracked properties must make tradeoffs to balance performance, simplicity, and usability. Tracked properties make a different set of tradeoffs than today's computed properties.

This means tracked properties come with edge cases or "gotchas" that don't exist in computed properties. When evaluating the following drawbacks, please consider the two features in their totality, including computed property gotchas you have learned to work around.

In particular, please try to compensate for familiarity and loss aversion biases. Before you form a strong opinion, give it five minutes.

Tracked Properties & Promises

Dependency autotracking requires that tracked getters access their dependencies synchronously. Any access that happens asynchronously will not be detected as a dependency.

This is most commonly encountered when trying to return a Promise from a tracked getter. Here's an example that would "work" but would never update if firstName or lastName change:

class Person {
  @tracked firstName;
  @tracked lastName;

  get fullNameAsync() {
    return this.reloadUser().then(() => {
      return `${this.firstName} ${this.lastName}`;
    });
  }

  async reloadUser() {
    const response = await fetch('https://example.com/user.json');
    const { firstName, lastName } = await response.json();
    this.firstName = firstName;
    this.lastName = lastName;
  }

  setFirstName(firstName) {
    // This should cause `fullNameAsync` to update, but doesn't, because
    // firstName was not detected as a dependency.
    this.firstName = firstName;
  }
}

One way you could address this is to ensure that any dependencies are consumed synchronously:

get fullNameAsync() {
  // Consume firstName and lastName so they are detected as dependencies.
  let { firstName, lastName } = this;

  return this.reloadUser().then(() => {
    // Fetch firstName and lastName again now that they may have been updated
    let { firstName, lastName } = this;
    return `${firstName} ${lastName}`;
  });
}

However, modeling async behavior as tracked properties is an incoherent approach and should be discouraged. Tracked properties are intended to hold simple state, or to derive state from data that is available synchronously.

But asynchrony is a fact of life in web applications, so how should we deal with async data fetching?

In keeping with Data Down, Actions Up, async behavior should be modeled as methods that set tracked properties once the behavior is complete.

Async behavior should be explicit, not a side-effect of property access. Today's computed properties that rely on caching to only perform async behavior when a dependency changes are effectively reintroducing observers into the programming model via a side channel.

A better approach is to call a method to perform the async data fetching, then set one or more tracked properties once the data has loaded. We can refactor the above example back to a synchronous fullName tracked property:

class Person {
  @tracked firstName;
  @tracked lastName;

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

  async reloadUser() {
    const response = await fetch('https://example.com/user.json');
    const { firstName, lastName } = await response.json();
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

Now, reloadUser() must be called explicitly, rather than being run implicitly as a side-effect of consuming fullName.

Accidental Untracked Properties

One of the design principles of tracked properties is that they are only required for state that changes over time. Because tracked properties imply some overhead over an untracked property (however small), we only want to pay that cost for properties that actually change.

However, an obvious failure mode is that some property does change over time, but the user simply forgets to annotate that property as @tracked. This will cause frustrating-to-diagnose bugs where the DOM doesn't update in response to property changes.

Fortunately, we have a strategy for mitigating some of this frustration. It involves the way most tracked properties will be consumed: via a component template. In development mode, we can detect when an untracked property is used in a template and install a setter that causes an exception to be thrown if it is ever mutated. (This is similar to today's "mandatory setter" that causes an exception to be thrown if a watched property is set without going through set().)

Unfortunately this strategy cannot be applied to values accessed by tracked getters. The only way we could detect such access would be with native Proxies, but proxies are more focussed on security over flexibility and recent discussion shows that they may break entirely when used with private fields. As such, it would not be ideal for us to use them in this way.

Alternatives

Ship tracked properties in user-land

Instead of shipping @tracked today, we can focus on formalizing the primitives which it uses under the hood in Glimmer VM (References and Validators) and make these publicly consumable. This way, users will be able to implement tracked in an addon and experiment with it before it becomes a core part of Ember.

This approach is similar to the approach taken with component managers in the past year, which unblocked experimentation with SparklesComponents as a way to validate the design of GlimmerComponents, and unlocked the ability for power users to create their own component APIs. However, the reference and validator system is a much more core part of the Glimmer VM, and it could take much longer to figure out the best and safest way to do this without exposing too much of the internals. It would certainly prevent @tracked from shipping with Ember Octane.

Keep the current system

We could keep the current computed property based system, and refactor it internally to use references only and not rely on chains or the old property notification system. This would be difficult, since CPs are very intertwined with property events as are their dependencies. It would also mean we wouldn't get the DX benefits of cleaner syntax, and the performance benefits of opt-in change tracking and caching.

We could keep set

Tracked properties were designed around wanting to use native setters to update state. If we remove that constraint and keep set, it opens up some possibilities. There is precedent for this in other frameworks, such as React's setState.

However, keeping set likely wouldn't be able to restrict the requirement for @tracked being applied to all mutable properties for the same reason get must be used in interop - there's no way for a tracked property to know that a plain, undecorated property could update in the future.

Allow explicit dependencies

We could allow @tracked to receive explicit dependencies instead of forcing get usage for interop. This would be very complex, if even possible, and is ultimately not functionality @tracked should have in the long run, so it would not make sense to add it now.

We could wait on private fields and Proxy developments

Native Proxies represent a lot of possibilities for automatic change tracking. Other frameworks such as Vue and Aurelia are looking into using recursive proxy structures to wrap objects and intercept access, which would allow them to track changes without any decoration. We also considered using recursive proxies in earlier drafts of this proposal, even though they aren't part of our support matrix we believed they could be used during development to assert when users attempted to update untracked properties which had been consumed from tracked getters.

However, as mention above, TC39 has made it clear that this was not an intended use for Proxy, and they will be breaking this functionality with the inclusion of private fields. They have also expressed that they would like to solve this use-case (observing object state changes in general) separately, and a strawman proposal was made (though it has not advanced and does not seem like it will). We could wait to see what the future looks like here, and see if we can provide a more ergonomic tracked properties RFC in the future.

Unresolved questions

When can I stop using get and set?

This is the biggest open question in this RFC, and with the direction that tracked properties set. How do we get rid of get and set for good, if that is the direction we want to go in?

The full answer to that question is out of scope for tracked properties, but it would likely require at least two additional steps:

  1. The underlying system for tracking changes, including the ability to create tags for fields and the ability to add to the current autotracking stack, will need to be made public for advanced users who need dynamic change tracking.

  2. First class support for native proxies within Ember. unknownProperty and setUnknownProperty have no other analag in native Javascript, and without support for native proxies there will likely be use cases that cannot be supported in any other way.

    As mentioned above, native proxies will (potentially) have more limitations than Ember proxies, but these limitations will most likely be possible to work around for advanced users who need this functionality in the first place. In other words, while they probably don't make sense as a basis for all change tracking in Ember, they will probably be invaluable for specific use cases such as Ember M3 which require very dynamic change tracking.