Autotracking
Reactivity and State in Modern Ember
Chris Hewell Garrett
@pzuraq
Reactivity
?
How apps update when things change
Reactivity
Autotracking
Updating
Let's build an app!
<h1>todos</h1>
<main>
<input
type="text"
placeholder="What needs to be done?"
{{on "keyup" this.addItem}}
/>
{{#each this.displayingTodos as |todo|}}
<div class="todo {{if todo.completed "completed"}}">
<input type="checkbox" {{on "change" this.toggle todo}}/>
{{todo.title}}
</div>
{{/each}}
<footer>
<span class="todos-left">
{{this.todosLeft}} items left
</span>
<button {{on "click" (fn this.show "all"}}>
All
</button>
<button {{on "click" (fn this.show "active")}}>
Active
</button>
<button {{on "click" (fn this.show "completed")}}>
Completed
</button>
</footer>
</main>
class TodoList extends Component {
allTodos = [
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
];
displaying = 'all';
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
get todosLeft() {
return this.uncompletedTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.push({
text: target.value,
completed: false,
});
target.value = '';
}
@action toggle(todo) {
todo.completed = !todo.completed;
}
@action show(type) {
this.displaying = type;
}
}
<h1>todos</h1>
<main>
<input
type="text"
placeholder="What needs to be done?"
{{on "keyup" this.addItem}}
/>
{{#each this.displayingTodos as |todo|}}
<div class="todo {{if todo.completed "completed"}}">
<input type="checkbox" {{on "change" this.toggle todo}}/>
{{todo.title}}
</div>
{{/each}}
<footer>
<span class="todos-left">
{{this.todosLeft}} items left
</span>
<button {{on "click" (fn this.show "all"}}>
All
</button>
<button {{on "click" (fn this.show "active")}}>
Active
</button>
<button {{on "click" (fn this.show "completed")}}>
Completed
</button>
</footer>
</main>
class TodoList extends Component {
allTodos = [
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
];
displaying = 'all';
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
get todosLeft() {
return this.activeTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.push({
text: target.value,
completed: false,
});
target.value = '';
}
@action toggle(todo) {
todo.completed = !todo.completed;
}
@action show(type) {
this.displaying = type;
}
}
class TodoList extends Component {
allTodos = [
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
];
displaying = 'all';
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
get todosLeft() {
return this.activeTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.push({
text: target.value,
completed: false,
});
target.value = '';
this.rerender();
}
@action toggle(todo) {
todo.completed = !todo.completed;
this.rerender();
}
@action show(type) {
this.displaying = type;
this.rerender();
}
}
class TodoList extends Component {
allTodos = [
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
];
displaying = 'all';
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
get todosLeft() {
return this.activeTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.push({
text: target.value,
completed: false,
});
target.value = '';
this.rerenderList();
this.rerenderItemsLeft();
}
@action toggle(todo) {
todo.completed = !todo.completed;
this.rerenderItem(todo);
this.rerenderItemsLeft();
}
@action show(type) {
this.displaying = type;
this.rerenderList();
}
}
Annotation Overhead
Reactivity
Reactivity: Declarative programming model for updating based on changes to state
Reactivity:
Declarative is about "what, not how"
<h1>Log In</h1>
<form>
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
</form>
HTML
Reactivity: Declarative programming model for updating based on changes to state
All the things that can change in your app
- Variables
- Properties
- Inputs
Root State vs. Derived State
class Person extends Component {
@tracked firstName = 'Liz';
@tracked lastName = 'Hewell';
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
Root State
Derived State
Root State
HTML
(Derived State)
Reactivity: Declarative programming model for updating based on changes to state
Reactive Solutions
Observables
Events
Transformed
Events
class TodoList extends Component {
allTodos = Observable.of([
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
]);
displaying = Observable.of('all');
activeTodos = allTodos.map(arr =>
arr.filter(todo => !todo.completed)
);
completedTodos = allTodos.map(arr =>
arr.filter(todo => todo.completed)
);
displayingList = Observable.forkJoin({
displaying: this.displaying,
all: this.allTodos,
active: this.activeTodos,
completed: this.completedTodos,
}).map(
values => values[values.displaying]
);
todosLeft = activeTodos.map(arr => arr.length);
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
let lastTodos = this.allTodos.last();
this.allTodos.next([...lastTodos, {
text: this.input,
completed: false,
}]);
target.input = '';
}
@action toggle(todo) {
let todos = this.allTodos.last().slice();
let index = todos.indexOf(todo);
todos[index] = {
...todo,
todo: !todo.completed
};
this.allTodos.next(todos);
}
@action show(type) {
this.displaying.next(type);
}
}
class TodoList extends Component {
allTodos = A([
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
]);
displaying = 'all';
@computed('allTodos.[]')
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
@computed('allTodos.[]')
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
@computed('displaying')
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
@computed('completedTodos.[]')
get todosLeft() {
return this.activeTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.pushObject({
text: this.input,
completed: false,
});
target.value = '';
}
@action toggle(todo) {
set(todo, 'completed', !todo.completed);
}
@action show(type) {
set(this, 'displaying', type);
}
}
Performance++, Ergonomics--
Virtual DOM
Dirty
Re-render
class TodoList extends Component {
state = {
allTodos: [
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
],
displaying: 'all',
};
get activeTodos() {
return this.state.allTodos.filter(
todo => !todo.completed
);
}
get completedTodos() {
return this.state.allTodos.filter(
todo => todo.completed
);
}
get displayingTodos() {
return this[`${this.state.displaying}Todos`];
}
get todosLeft() {
return this.activeTodos.length;
}
@bound addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
let allTodos = [
...this.state.allTodos,
{
text: target.value,
completed: false,
}
];
target.value = '';
this.setState({ allTodos });
}
@bound toggle(todo) {
let allTodos = this.state.allTodos.last().slice();
let index = todos.indexOf(todo);
allTodos[index] = {
...todo,
todo: !todo.completed
};
this.setState({ allTodos });
}
@bound show(type) {
this.setState({ displaying: type });
}
}
function TodoList() {
let [allTodos, setTodos] = useState([
{
title: 'Code things',
completed: false,
},
{
title: 'Plan EmberConf talk',
completed: true,
}
]);
let [displaying, setDisplaying] = useState('all');
let activeTodos = allTodos.filter(todo => !todo.completed);
let completedTodos = allTodos.filter(todo => todo.completed);
let displayingTodos =
displaying === 'all' ? allTodos :
displaying === 'active' ? activeTodos :
displaying === 'completed' ? completedTodos : [];
let todosLeft = activeTodos.length;
// ...
-
useMemo
-
shouldComponentUpdate
- Concurrent Mode
Let's step back
Observables
- Very performant
- Lot's of annotation
- Always have to think about reactivity when writing code
Virtual DOM
- Minimal annotation
- Very flexible
- Not very performant, requires manual optimization
Enter Autotracking
Use the JavaScript State Model
class Todo {
@tracked title;
@tracked completed;
constructor(title, completed) {
this.title = title;
this.completed = completed;
}
}
class TodoList extends Component {
allTodos = new TrackedArray([
new Todo('Code things', false),
new Todo('Plan EmberConf talk', true),
]);
@tracked displaying = 'all';
get activeTodos() {
return this.allTodos.filter(todo => !todo.completed);
}
get completedTodos() {
return this.allTodos.filter(todo => todo.completed);
}
get displayingTodos() {
return this[`${this.displaying}Todos`];
}
get todosLeft() {
return this.activeTodos.length;
}
@action addItem({ key, target }) {
if (key !== 'Enter' || !target.value) return;
this.allTodos.push({
text: target.value,
completed: false,
});
target.value = '';
}
@action toggle(todo) {
todo.completed = !todo.completed;
}
@action show(type) {
this.displaying = type;
}
}
Ergonomics++
Performance++
Root State
Output
Memoization
// Basic memoization in JS
let lastArgs;
let lastResult;
function memoizedRender(...args) {
if (deepEqual(lastArgs, args)) {
return lastResult;
}
lastResult = render(...args);
lastArgs = args;
return lastResult;
}
class Item {
@tracked name;
constructor(name) {
this.name = name;
}
}
class State {
@tracked showItems = false;
@tracked selectedType = 'Fruits';
@tracked itemTypes = [
'Fruits',
'Vegetables',
]
@tracked fruits = [
new Item('Banana'),
new Item('Orange'),
];
@tracked vegetables = [
new Item('Celery'),
new Item('Broccoli'),
];
}
let state = new State();
const InventoryComponent = memoize(() => {
if (!state.showItems) return '';
let { selectedType } = state;
let typeOptions = state.itemTypes.map(type =>
OptionComponent(type);
);
let items = state[selectedType.toLowerCase()];
let listItems = items.map(item =>
ListItemComponent(item.name);
);
return `
<select>${typeOptions}</select>
<ul>${listItems}</ul>
`;
});
let output = InventoryComponent(state);
class Item {
@tracked name;
constructor(name) {
this.name = name;
}
}
class State {
@tracked showItems = false;
@tracked selectedType = 'Fruits';
@tracked itemTypes = [
'Fruits',
'Vegetables',
]
@tracked fruits = [
new Item('Banana'),
new Item('Orange'),
];
@tracked vegetables = [
new Item('Celery'),
new Item('Broccoli'),
];
}
let CURRENT_REVISION = 0;
dirty() {
CURRENT_REVISION++;
}
0
Clock
0
Clock
state v0
State Timeline
1
Clock
state v0
state v1
State Timeline
2
Clock
state v2
state v0
state v1
State Timeline
15156
Clock
state v2
state v15156
...
state v0
state v1
State Timeline
Tag
myClass.trackedProp
Tag
8034
Tag
15156
Clock
myClass.trackedProp
8034
Tag
15156
Clock
myClass.trackedProp++
8034
Tag
15157
15156
Clock
myClass.trackedProp++
8034
Tag
15157
15157
15156
Clock
myClass.trackedProp++
8034
let state = new State();
const InventoryComponent = memoize(() => {
if (!state.showItems) return '';
let { selectedType } = state;
let typeOptions = state.itemTypes.map(type =>
OptionComponent(type);
);
let items = state[selectedType.toLowerCase()];
let listItems = items.map(item =>
ListItemComponent(item.name);
);
return `
<select>${typeOptions}</select>
<ul>${listItems}</ul>
`;
});
let output = InventoryComponent(state);
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
class Item {
@tracked name;
constructor(name) {
this.name = name;
}
}
class State {
@tracked showItems = false;
@tracked selectedType = 'Fruits';
@tracked itemTypes = [
'Fruits',
'Vegetables',
]
@tracked fruits = [
new Item('Banana'),
new Item('Orange'),
];
@tracked vegetables = [
new Item('Celery'),
new Item('Broccoli'),
];
}
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
4
6
6
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
4
6
7
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
7
6
7
7
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
7
7
7
some.other
4
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
7
8
7
some.other
4
showItems
selectedType
itemTypes
fruits
banana.name
orange.name
2
1
3
6
5
7
8
7
some.other
8
- Dirty: Increment a number
- Validate: Array.map + Math.max
- Lazy, only checked on demand
Only need annotate root state!
This is what bending the curve looks like.
What's next?
Libraries, patterns, common abstractions
import {
TrackedArray,
TrackedMap,
TrackedSet,
} from 'tracked-built-ins';
let arr = new TrackedArray([1, 2, 3]);
let map = new TrackedMap();
let set = new TrackedSet();
Tooling!
Autotracking > Ember
Thanks!
Read More: www.pzuraq.com/tag/autotracking
Autotracking: Reactivity and State in Modern Ember
By pzuraq
Autotracking: Reactivity and State in Modern Ember
Tracked properties are one of the most exciting features introduced in Ember Octane, and they represent a shift in the model for state management in modern Ember apps. But what makes a property "tracked"? Why do we have to decorate our properties? And how does this all differ from how other web frameworks think about state? In this deep dive talk, I'll discuss the problems of state management and reactivity, and a number of solutions that have evolved over the years. I'll also show the internals of autotracking, and demonstrate some the unique benefits it gives to developers!
- 1,471