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!