Fully Reactive User Interfaces

Sébastien Cevey

@theefer

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 == 5

Values 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

@theefer

Fully Reactive User Interfaces

By Sébastien Cevey

Fully Reactive User Interfaces

  • 5,483