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 staticextend
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:
- The
SomeComponent
component is rendered for the first time, instantiating theConfig
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. - 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. - 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. - 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:
- Objects that implement
unknownProperty
andsetUnknownProperty
- Ember proxies,
which use
unknownProperty
andsetUnknownProperty
- 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:
- General usage. Which properties should I mark as tracked? How do I consume them? How do I trigger changes?
- 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?
- 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:
- Accessing non-tracked properties and computeds from an autotrack context
- 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 SparklesComponent
s as a way to
validate the design of GlimmerComponent
s, 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:
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.
First class support for native proxies within Ember.
unknownProperty
andsetUnknownProperty
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.