Autotracking is how Ember's reactivity model works - how it decides what to rerender, and when. This guide covers tracking in more depth, including how it can be used in various types of classes, and how it interacts with arrays and POJOs.
Autotracking Basics
When Ember first renders a component, it renders the initial state of that component - the state of the instance, and state of the arguments that are passed to it:
import Component from '@glimmer/component';
export default class HelloComponent extends Component {
language = 'en';
get greeting() {
switch (this.language) {
case 'en':
return 'Hello';
case 'de':
return 'Hallo';
case 'es':
return 'Hola';
}
}
}
When Ember renders this template, we get:
Hello, Jen Weber!
By default, Ember assumes that none of the values that are rendered will ever
change. In some cases this is clearly true - for instance, the punctuation in
the template will always be the same, so Ember doesn't need to do anything to
update it. These are static, state-less parts of the template. In other cases,
like this.greeting
or @name
argument, that's less clear. It appears
language
might be something we want to update, and if we do, then greeting
should probably change, right? At the least, we should check to see if it
should change.
In order to tell Ember a value might change, we need to mark it as trackable. Trackable values are values that:
- Can change over their component’s lifetime and
- Should cause Ember to rerender if and when they change
We can do this by marking the field with the @tracked
decorator:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class HelloComponent extends Component {
@tracked language = 'en';
get greeting() {
switch (this.language) {
case 'en':
return 'Hello';
case 'de':
return 'Hallo';
case 'es':
return 'Hola';
}
}
}
When Ember renders a value like {{this.greeting}}
in the template, it takes
note of any tracked properties that it encounters, in this case language
. If
these values change in the future, it schedules a rerender, and then updates
only the values that could have changed. This means that when language
changes, only the Hello
text in the browser will rerender - Ember leaves the
, Jen Weber!
portion completely alone!
Arguments, like {{@name}}
, are automatically tracked, so if they change and
are used somewhere in your component, the component will update accordingly.
Updating Tracked Properties
Tracked properties can be updated like any other property, using standard JavaScript syntax. For instance, we could update a tracked property via an action, as in this example component.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class HelloComponent extends Component {
@tracked language = 'en';
get greeting() {
switch (this.language) {
case 'en':
return 'Hello';
case 'de':
return 'Hallo';
case 'es':
return 'Hola';
}
}
@action
updateLanguage(event) {
this.language = event.target.value;
}
}
Now, whenever we change the value of the select
, it'll call the action method,
which will set the value of language
. Since language
is marked as tracked,
and was used in rendering greeting
, Ember will know that greeting
needs to
be re-rendered in the template, and will update.
Another way that a tracked property could be updated is asynchronously, if you're sending a request to the server. For instance, maybe we would want to load the user's preferred language:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class HelloComponent extends Component {
constructor() {
super(...arguments);
fetch('/api/preferences')
.then(r => r.json()) // convert the response to a JS object
.then(response => {
this.language = response.preferredLanguage;
});
}
@tracked language = 'en';
get greeting() {
switch (this.language) {
case 'en':
return 'Hello';
case 'de':
return 'Hallo';
case 'es':
return 'Hola';
}
}
}
This will also trigger a rerender. No matter where the update occurs, updating a tracked property will let Ember know to rerender any affected portion of the app.
Tracking Through Methods
So far we've only shown tracked properties working through getters, but tracking works through methods or functions as well:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class HelloComponent extends Component {
@tracked language = 'en';
@tracked supportedLanguages = ['en', 'de', 'es'];
isSupported(language) {
return this.supportedLanguages.includes(language);
}
get greeting() {
if (!this.isSupported(this.language)) {
return 'Unsupported Language';
}
switch (this.language) {
case 'en':
return 'Hello';
case 'de':
return 'Hallo';
case 'es':
return 'Hola';
}
}
}
if supportedLanguages
changes here, greeting
will update as well! This
code could likely be refactored to use getters, but in cases where a function or
method makes more sense, tracked properties will still work.
Tracked Properties in Custom Classes
Tracked properties can also be applied to your own custom classes, and used within your components and routes:
export default class Person {
@tracked title;
@tracked name;
constructor(title, name) {
this.title = title;
this.name = name;
}
get fullName() {
return `${this.title} ${this.name}`;
}
}
import Route from '@ember/routing/route';
import Person from '../../../../utils/person';
export default class ApplicationRoute extends Route {
model() {
return new Person('Dr.', 'Zoey');
}
}
import Controller from '@ember/controller';
import { action } from '@ember/object';
export default class ApplicationController extends Controller {
@action
updateName(title, name) {
this.model.title = title;
this.model.name = name;
}
}
As long as the properties are tracked, and accessed when rendering the template directly or indirectly, everything should update as expected
Plain Old JavaScript Objects (POJOs)
Generally, you should try to create classes with their tracked properties
enumerated and decorated with @tracked
, instead of relying on dynamically
created POJOs. In some cases however, if your usage of properties on POJOs is
too dynamic, you may not be able to enumerate every single property that could
be tracked. There could be a prohibitive number of possible properties, or there
could be no way to know them in advance. In this case, it's recommended that you
reset the value wherever it is updated:
class SimpleCache {
@tracked _cache = {};
set(key, value) {
this._cache[key] = value;
// trigger an update
this._cache = this._cache;
}
get(key) {
return this._cache[key];
}
}
Triggering an update like this will cause any getters that used the cache
to
recalculate. Note that we can use the get
method to access the cache, and it
will still push the _cache
tracked property.
Arrays
Arrays are another example of a type of object where you can't enumerate every
possible value - after all, there are an infinite number of integers (though you
may run out of bits in your computer at some point!). Instead, you can
continue to use EmberArray
, which will continue to work with tracking and will
cause any dependencies that use it to invalidate correctly.
import { A } from '@ember/array';
class ShoppingList {
items = A([]);
addItem(item) {
this.items.pushObject(item);
}
}
Caching of tracked properties
In contrast to computed properties from pre-Octane, tracked properties are not cached. A tracked property can also be recomputed even though its dependencies haven't changed. The following example shows this behavior:
import { tracked } from '@glimmer/tracking';
let count = 0;
class Photo {
@tracked width = 600;
@tracked height = 400;
get aspectRatio() {
count++;
return this.width / this.height;
}
}
let photo = new Photo();
console.log(photo.aspectRatio); // 1.5
console.log(count); // 1
console.log(photo.aspectRatio); // 1.5
console.log(count); // 2
photo.width = 800;
console.log(photo.aspectRatio); // 2
console.log(count); // 3
From the value of count
, we see that aspectRatio
was calculated 3 times.
Recomputing is fine in most cases. If the computation that happens in the getter is very expensive, however, you will want to cache the value and retrieve it when the dependencies haven't changed. You want to recompute only if a dependency has been updated.
Ember's cache API lets you cache a getter in 2 steps:
- Pass a function that is costly to compute to
createCache
. - In the getter, call the function with
getValue
and return its value.
With these steps in mind, let's introduce caching to aspectRatio
:
import { tracked } from '@glimmer/tracking';
import { createCache, getValue } from '@glimmer/tracking/primitives/cache';
let count = 0;
class Photo {
@tracked width = 600;
@tracked height = 400;
// `#` indicates a private field on the class
#aspectRatioCache = createCache(() => {
count++;
return this.width / this.height;
});
get aspectRatio() {
return getValue(this.#aspectRatioCache);
}
}
let photo = new Photo();
console.log(photo.aspectRatio); // 1.5
console.log(count); // 1
console.log(photo.aspectRatio); // 1.5
console.log(count); // 1
photo.width = 800;
console.log(photo.aspectRatio); // 2
console.log(count); // 2
From the value of count
, we see that, this time, aspectRatio
was calculated
only twice.
The cache API was released in Ember 3.22. If you want to leverage this API between versions 3.13 and 3.21, you can install ember-cache-primitive-polyfill to your project.