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 == 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
Fully Reactive User Interfaces
By Sébastien Cevey
Fully Reactive User Interfaces
- 5,443