Fully Reactive User Interfaces

Sébastien Cevey

Reactive Programming
to solve the
hard problems of building UIs
Use
hard problems of building UIs
State
Async
function updateConverted() {
  const amount = $('.amount').val();
  const result = amount * rate;
  $('.converted').text(result);
}
$('.amount').keyup(updateConverted);
updateConverted(); // init$('.reset').click(function() {
  $('.amount').val(1);
  $('.currency').val('EUR');
  updateRate();
});let rate;
$('.currency').change(function() {
  const currency = this.value;
  updateRate(currency);
});
function updateRate(currency) {
  $('.loading-rate').show();
  lookupRate(currency).then(newRate => {
    $('.loading-rate').hide();
    $('.rate').text(newRate);
    rate = newRate;
    updateConverted();
  });
}
updateRate('EUR'); // init

(loading)
Source
State
Replicated
Derived
Implied

I/O
User interactions
Async
Reactive Programming
hard problems of building UIs
to solve the
Use
Reactive Programming
a := 1
b := 2
c := a + b  // c == 3
a := 3      // c == 5Values that vary over time
aka Observable Streams
a := 1
b := 2
c := a + b  // c == 3
a := 3      // c == 5// Emits a new incrementing number every second
const seconds$ = Rx.Observable.interval(1000);...
...
seconds
0
0
1
2
3
4
1
2
3
4
// Emits a new incrementing number every second
const seconds$ = Rx.Observable.interval(1000);const fromTen$ = seconds$.map(n => n + 10);...
...
seconds
0
0
1
2
3
4
1
2
3
4
...
10
11
12
13
14
// Emits a new incrementing number every second
const seconds$ = Rx.Observable.interval(1000);const fromTen$ = seconds$.map(n => n + 10);const even$ = fromTen$.filter(n => n % 2 == 0);...
...
seconds
0
0
1
2
3
4
1
2
3
4
...
10
11
12
13
14
...
10
12
14
// Emits a new incrementing number every second
const seconds$ = Rx.Observable.interval(1000);const fromTen$ = seconds$.map(n => n + 10);const even$ = fromTen$.filter(n => n % 2 == 0);const delayed$ = even$.delay(1000);...
...
seconds
0
0
1
2
3
4
1
2
3
4
...
10
11
12
13
14
...
10
12
14
...
10
12
14
const seconds$ = Rx.Observable.interval(1000);
seconds$.take(3).subscribe(sec => {
  console.log("second: ", sec);
});
/* Outputs
 *
 *   second: 0
 *   second: 1
 *   second: 2
 *
 * and terminates.
 */
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
function model(intents) {Model
Intents
State as streams
  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    scan((state, func) => func(state), 1);

  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);  const currency$ = intents.updateCurrency$.
    startWith(init).
    scan((state, func) => func(state), 'EUR');function model(intents) {Model
Intents
State as streams

  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);  const currency$ = intents.updateCurrency$.
    startWith(init).
    scan((state, func) => func(state), 'EUR');  const rate$ = currency$.
    map(currency => lookupRate(currency)).
    flatMap(p => Rx.Observable.fromPromise(p));function model(intents) {Model
Intents
State as streams

  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);  const currency$ = intents.updateCurrency$.
    startWith(init).
    scan((state, func) => func(state), 'EUR');  const rate$ = currency$.
    map(currency => lookupRate(currency)).
    flatMap(p => Rx.Observable.fromPromise(p));function model(intents) {  const converted$ = Rx.Observable.combineLatest(
    amount$, rate$,
    (amount, rate) => amount * rate);
Model
Intents
State as streams

  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);  const currency$ = intents.updateCurrency$.
    startWith(init).
    scan((state, func) => func(state), 'EUR');  const rate$ = currency$.
    map(currency => lookupRate(currency)).
    flatMap(p => Rx.Observable.fromPromise(p));function model(intents) {  const converted$ = Rx.Observable.combineLatest(
    amount$, rate$,
    (amount, rate) => amount * rate);
Model
Intents
State as streams
  const rateLoading$ = Rx.Observable.merge(
    currency$.map(true),
    rate$.map(false)
  );

  const init = (initialState) => initialState;
  // Intent:
  //   Observable< Function< State => State > >
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1);  const currency$ = intents.updateCurrency$.
    startWith(init).
    scan((state, func) => func(state), 'EUR');  const rate$ = currency$.
    map(currency => lookupRate(currency)).
    flatMap(p => Rx.Observable.fromPromise(p));function model(intents) {  return {
    amount$, currency$, rate$, converted$,
    rateLoading$
  };
}  const converted$ = Rx.Observable.combineLatest(
    amount$, rate$,
    (amount, rate) => amount * rate);
Model
Intents
State as streams
  return {
    amount$: amount$,
    currency$: currency$,
    ...
  };  const rateLoading$ = Rx.Observable.merge(
    currency$.map(true),
    rate$.map(false)
  );

const tree = h('main', [
  h('form', [
    h('label', [
      h('span.label', 'Amount'),
      h('input', {
        type: 'text',
        value: '5',
        oninput: function(ev) { ... }
      })),
      ' GBP'
    ]),
<main>
  <form>
    <label>
      <span class="label">Amount</span>
      <input
             type="text"
             value="5"
             oninput="..."
      />
      GBP
    </label>
function view() {  const events = {
    amountChanged$:   new Rx.Subject(),
  };
Model
Intents
View
tree$
  function tree$(model) {
    return h('main', [
      h('form', [
        h('label', [
          h('span.label', 'Amount'),
          h('input', {
            type: 'text',
            value: ? amount ?,
          }),
          ' GBP']),
  }
Interactions & DOM
as streams
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            oninput: (event) => {
              ???
            }})),
          ' GBP']),
  }
  function tree$(model) {
    return h('main', [
      h('form', [
        h('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            })),
          ' GBP']),
  }
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            })),
          ' GBP']),
  }
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            oninput: (event) => {
              events.amountChanged$.onNext(event)
            }})),
          ' GBP']),
  }
events

function view() {  return {
    tree$, events
  };
}  const events = {
    amountChanged$:   new Rx.Subject(),
    currencyChanged$: new Rx.Subject(),
    resetClicked$:    new Rx.Subject()
  };
Model
Intents
View
tree$
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            oninput: (event) => {
              events.amountChanged$.onNext(event)
            }})),
          ' GBP']),
      // ...
      h$('div.converted', [
        h('span.label', 'Converted'),
        model.converted$,
        ' ',
        model.currency$]),
  }
Interactions & DOM
as streams
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            oninput: (event) => {
              events.amountChanged$.onNext(event)
            }})),
          ' GBP']),
  }
  function tree$(model) {
    return h$('main', [
      h$('form', [
        h$('label', [
          h('span.label', 'Amount'),
          model.amount$.map(amount => h('input', {
            type: 'text',
            value: amount,
            oninput: (event) => {
              events.amountChanged$.onNext(event)
            }})),
          ' GBP']),
      // ...
      h$('div.converted', [
        h('span.label', 'Converted'),
        model.converted$,
        ' ',
        model.currency$]),
      h('button.reset', {
        type: 'button',
        onclick: sink$(events.resetClicked$)
      }, 'Reset')]);
  }
events

function intents(events) {  const updateAmount$ = Rx.Observable.merge(
    events.amountChanged$.
      map(event => event.target.value).
      map(newAmount => current => newAmount),
    events.resetClicked$.
      map(event => current => 1),
    events.incrementClicked$.
      map(event => current => current + 1),
    events.decrementClicked$.
      map(event => current => current - 1)
  );Intents
events

tree$
View
Model
Intents as streams
  const updateAmount$ = Rx.Observable.merge(
    events.amountChanged$.
      map(event => event.target.value).
      map(newAmount => current => newAmount),
    events.resetClicked$.
      map(event => current => 1)
  );  const updateAmount$ = Rx.Observable.merge(
    events.amountChanged$.
      map(event => event.target.value).
      map(newAmount => current => newAmount)
  );old state
new state
=>

function intents(events) {  return {
    updateAmount$,
    updateCurrency$
  };
}  const updateAmount$ = Rx.Observable.merge(
    events.amountChanged$.
      map(event => event.target.value).
      map(newAmount => current => newAmount),
    events.resetClicked$.
      map(event => current => 1),
    events.incrementClicked$.
      map(event => current => current + 1),
    events.decrementClicked$.
      map(event => current => current - 1)
  );Intents

  const updateCurrency$ = Rx.Observable.merge(
    events.currencyChanged$.
      map(event => event.target.value).
      map(newCurrency => current => newCurrency),
    events.resetClicked$.
      map(event => current => 'EUR')
  );
tree$
View
Model
Intents as streams
events
const theView    = view();
const theIntents = intents(theView.events);
const theModel   = model(theIntents);
const tree$      = theView.tree$(theModel);
const out = document.getElementById('out');
// Helper to subscribe & apply patches to the DOM
renderToDom(tree$, out);
// Observable< VDOM > => Observable< VPatches >Intents
DOM
tree$
View
Model
events
Clarity
Performance
  const amount$ = intents.updateAmount$.
    startWith(init).
    scan((state, func) => func(state), 1).
    distinctUntilChanged().
    debounce(300 /* ms */);  // [...]
  h$('div.converted', [
    h('span.label', 'Converted'),
      model.converted$,
      ' ',
      model.currency$]),
  // [...]


viewport
loaded cells
tableHeight$
itemCount$
items$
searchQuery$
resizeEvents$
windowWidth$
columnCount$
rangeToLoad$
viewportTop$
scrollEvents$
resizeEvents$
viewportHeight$
cellPosition$(item)
itemIndex$
items$
searchQuery$
resizeEvents$
windowWidth$
columnCount$
top, left, width, height
Reactive Programming
to solve the
hard problems of building UIs
Use
Questions?
Sébastien Cevey

Fully Reactive User Interfaces
By Sébastien Cevey
Fully Reactive User Interfaces
- 5,894