- Start Date: 2018-12-05
- RFC PR: (leave this empty)
- Ember Issue: (leave this empty)
- Authors: Tom Dale, Chris Garrett, Chad Hietala, Yehuda Katz
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';
@tracked get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
Decorators are important to adopting native class syntax. They are a formalization of the patterns we have been using as a community for years, and it will not be possible to use native classes ergonomically without them unless a number of major concepts (computed properties, injections, actions) are rethought. That said, decorators are still a stage 2 proposal in TC39, which means that while they are fully defined as a spec, they are not yet considered a candidate for inclusion in the language, and may have incremental changes that could be breaking if/when moved to stage 3. As such, merging support for them now would pose some risk.
This RFC proposes making the @tracked
decorator available in Ember for users
who are comfortable taking that risk today. Ember cannot guarantee that the spec
won't change, and such changes cannot apply to Ember's normal semver guarantees.
But it can make the following guarantees:
-
If there are changes to the spec, and it is possible to avoid changing the public APIs of decorators, then Ember will make the changes necessary to avoid public API changes.
-
If there are changes to the spec, and it is not possible to avoid breaking changes, Ember will minimize the changes as much as possible, and will provide a codemod to convert from the previous version of the spec to the next.
-
If the spec is dropped from TC39 altogether, Ember will continue to provide support for decorators via babel transforms until they are deprecated following the standard RFC process, and removed according to semver. Replacements for the core concepts of features that require decorators will be made.
It is possible that decorators will be advanced before this RFC closes and this will be a non-issue. Either way, if accepted, Ember would do the best it can to provide the stability the framework is known for throughout this process.
Because of the occasional overlap in terminology when discussing similar features, this document uses the following language consistently:
- A getter is a 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 produced by executing a function. That value is cached until one of computed property's dependencies changes.
- A tracked property refers to any property that has been instrumented with
@tracked
, either a tracked getter or a tracked simple property. - A tracked getter is a JavaScript getter that has been wrapped using the tracked decorator
- A tracked simple property is a regular, non-getter property that has been wrapped using the tracked decorator.
- 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.
- Native classes are classes defined using the Javascript
class
keyword. - Classic classes are classes defined by subclassing from
EmberObject
using the staticextend
method.
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.
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";
})
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 annoying 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 often obfuscates the intent of the property. People understand intuitively that they are typing out dependent keys to help Ember, not other programmers.
Second, people tell us that this syntax is not very intuitive. You have to read the Ember documentation at least once to understand what is happening in this example.
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 dependencies are marked as tracked, they will automatically be detected:
import { tracked } from '@glimmer/tracking';
class Person {
@tracked firstName = 'Tom';
@tracked lastName = 'Dale';
@tracked
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
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 properties should be marked as tracked, and immutable properties should not.
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 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.
This RFC proposes adding the tracked
decorator function, used to mark
properties and getters 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';
@tracked
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
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'];
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++;
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 getters can access. As tracked getters and 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';
@tracked
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
let person = new Person();
When we create a new instance of Person
, fullName
has no knowledge of
firstName
or lastName
. If we set either of those values now, it won't know
that anything has changed:
// Dirties the `firstName` property, but because `fullName` has not been
// accessed yet nothing happens to it.
person.firstName = 'Rob';
This is ok though, because nothing has accessed fullName
yet. There is no
state to invalidate anywhere else. Now, let's say we access fullName
, and then
update the field again:
person.fullName; // 'Rob Dale'
person.lastName = 'Jackson';
Now fullName
knows which tracked properties were accessed when it was run,
and setting lastName
has invalidated fullName
.
NOTE: This does not invalidate a cache like in computed properties. Even if
firstName
andlastName
were untracked, the tracked getter would still return the correct value on subsequent accesses, because@tracked
does not cache values. The validation and invalidation is pure meta data that is only accessible by the Glimmer VM.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
from React.To prevent inconsistency, during development time, tracked properties will keep a cache of their previous value to compare when they are activated and ensure that it hasn't changed without invalidation. This will prevent improper usage of tracked properties outside of Glimmer's change tracking.
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();
@tracked
get currentSeconds() {
return this.timer.seconds;
}
@tracked
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;
});
}
@tracked
get currentSeconds() {
return this.timer.seconds;
}
@tracked
get currentMinutes() {
return this.timer.minutes;
}
}
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
Tracked properties will not interoperate with observers, which are strictly within the old paradigm.
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' }),
fullName: tracked({
get() {
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 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; @tracked 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.
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;
@tracked
get pollInterval() {
let { shouldPoll, pollInterval } = this.config.polling;
return shouldPoll ? pollInterval : -1;
}
}
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 tracked
pollInterval
property first accesses the service injection, 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;
@tracked
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 tracked getter should be the conventional standard with the long term goal of removing all explicit dependencies.
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.
The mental model with tracked properties is that anything mutable should be
tracked. If a value will ever change, 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++;
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.
There are two cases that we need to consider when teaching interoperability:
- Tracked getters accessing non-tracked properties and computeds
- Computed getters accessing tracked properties
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.
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.
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.
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;
@tracked
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:
@tracked
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;
@tracked
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
.
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.
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.
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.
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.
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.
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.