Bacon/Kefir+React+Atom+Lenses – For Concise, Reactive UI – Background



Bacon/Kefir+React+Atom+Lenses – For Concise, Reactive UI – Background

0 0


training

http://calmm-js.github.io/training/

On Github calmm-js / training

Bacon/Kefir+React+Atom+Lenses

For Concise, Reactive UI

Vesa Karvonen

Background

  • Feenix / OVP UI
    • CMS, Packaging, Live streams, ...
  • Constraints
    • JavaScript + React
    • Had a meeting and was suggested
      • Redux, FFUX
      • But was given free hands! \o/

History

Got empty project from Matti Lankinen.

Matti had just noticed perf problems with megablob.

I had just used Reagent in production.

Refactoring Matti's project (createStore) created Atom.

And with Matti developed ways to embed Bacon into JSX.

Later started using Bacon Model and, thanks to it, lenses.

Realized Bacon Model creates cycles => Bacon Atom.

And then developed Partial Lenses library.

Desire

Avoid boilerplate and glue

Avoid all-or-nothing / lock-in

Prefer declarative

Avoid unnecessary encoding

Structural programming

Components plug-and-play

(Stole the image from a tweet.)

Fortunately

There is actually nothing new here.

We are just combining a few old things.

Examples

Clock

const oncePerSec = Kefir.constant().merge(Kefir.interval(1000))
export default () =>
  <K.div>{K(oncePerSec, () => new Date().toString())}</K.div>
export default ({elems = Atom([]), entry = Atom("")}) => <div>
    <div>
      <K.input type="text" {...bind({value: entry})}/>
      <button onClick={() => {const elem = entry.get().trim()
                              if (elem) {
                                elems.modify(R.append(elem))
                                entry.set("")}}}>
        Add
      </button>
    </div>
    <K.ul>
      {K(elems, es => es.map((e, i) => <li key={i}>{e}</li>))}
    </K.ul>
  </div>
const Slider = ({title, units, value, ...props}) => <div>
    <K.div>{title}: {value}{units}</K.div>
    <K.input type="range" {...bind({value})} {...props}/>
  </div>
const BMI = ({bmi}) =>
  <K.div {...classes("bmi", K(bmi, M.BMI.classification))}>
    <Slider title="Weight" units="kg" min={40}  max={140}
            value={bmi.lens(M.BMI.weight)}/>
    <Slider title="Height" units="cm" min={140} max={210}
            value={bmi.lens(M.BMI.height)}/>
    <K.div>BMI: <K.span className="bmi-value">{K(bmi, M.BMI.bmi)}</K.span></K.div>
  </K.div>
export default ({bmi}) => <BMI bmi={bmi.lens(M.BMI.augment)}/>
export const BMI = {
  augment: L.augment({
    bmi: ({height, weight}) =>
      Math.round(weight/(height * height * 0.0001))}),
  bmi: R.prop("bmi"),
  height: "height",
  weight: "weight",
  classification: ({bmi}) =>
      bmi < 15   ? "bmi-underweight bmi-underweight-severely"
    : bmi < 18.5 ? "bmi-underweight"
    : bmi < 25   ? "bmi-normal"
    : bmi < 30   ? "bmi-overweight"
    : bmi < 35   ? "bmi-obese"
    : bmi < 40   ? "bmi-obese bmi-obese-severely"
    :              "bmi-obese bmi-obese-very"
}
const Contact = ({contact}) => <div>
    <TextInput value={contact.lens(M.Contact.name)}/>
    <TextInput value={contact.lens(M.Contact.number)}/>
    <button onClick={() => contact.modify(M.Contact.remove)}>Remove</button>
  </div>
const Contacts = ({contacts}) => <K.div>
    {fromIds(K(contacts, M.Contacts.indices), i =>
     <Contact key={i} contact={contacts.lens(i)}/>)}
  </K.div>
export default ({phonebook = Atom(M.mock)}) => <div>
    <button onClick={() => phonebook.modify(M.Phonebook.addContact())}>
      New
    </button>
    <Contacts contacts={phonebook.lens(M.Phonebook.contacts)}/>
  </div>
export default ({value = Atom("")}) => {
  const editing = Atom(false)
  const exit = () => editing.set(false)
  const save = e => {value.set(e.target.value); exit(e)}
  return fromKefir(K(editing, e => e
    ? <K.input key="1" type="text" autoFocus
               defaultValue={value}
               onKeyDown={e => e.which === 13 && save(e)
                            || e.which === 27 && exit(e)}
               onBlur={save}/>
    : <K.input key="0" type="text" disabled
               {...{value}}
               onDoubleClick={() => editing.set(true)}/>))
}
export const mock =
  [{name: "Mr Digits", number: "1-23-456789"}]
export const Contact = {
  create: ({name = "", number = ""} = {}) => ({name, number}),
  remove: () => {},
  id: "id",
  name: "name",
  number: "number"
}
export const Contacts = {
  indices: R.pipe(R.length, iota)
}
export const Phonebook = {
  contacts: L.define([]),
  addContact: contact => R.append(Contact.create(contact))
}

What is difficult in UI Programming?

Maintaining consistent state in the face of async inputs.

Our Approach

In order of importance:

Specify dependent computations as observables. Embed observables directly into JSX. Store state in mutable observable Atoms. Use lenses to access state in Atoms.

All optional!

And can mix with other React components.

Atoms

Create new

Atom(vInitial)

For example:

const counter = Atom(0)

Inspect

atom.get()

For example:

counter.get() === 0

Mutate

atom.modify(vOld => vNew)

For example:

counter.modify(c => c + 1)

Now:

counter.get() === 1

Also for convenience:

atom.set(vNew) === atom.modify(() => vNew)

Atoms are first class objects

Can store in data structures, pass to and return from functions.

Want multiple things to always share the same value?

Put the value in an Atom and share it.

Atoms are observable

(Technically Atoms are Properties, a subclass of Observables.)

Avoid get.

Use FRP combinators to express dependent computations.

Dependent Computations

Observables

Stream

A B CD       E F             G      HIJ K L   M  N   O  P     QR   S
e.g. key down events, mouse clicks, ... filter, skip, merge, combine, scan, toProperty, ...

Property

AABBCDDDDDDDDEEFFFFFFFFFFFFFFGGGGGGGHIJJKKLKKKMMMNNNNOOOPPPPPPQRRRRS
e.g. text accumulated, position of pointer, ... combine, sample, changes, toEventStream, ...

Both are used. We are mostly concerned with properties.

Combining Properties

Bacon.combineWith(p1, ..., pN, (v1, ..., vN) => result)
Kefir.combine(p1, ..., pN, (v1, ..., vN) => result)

We abbreviate:

B(p1, ..., pN, (v1, ..., vN) => result)
K(p1, ..., pN, (v1, ..., vN) => result)

For example:

const input = Atom("This is an example.")
const words = K(input, R.split(" "))
const numWords = K(words, R.length)
const unique = K(words, R.uniqBy(R.toUpper))
const numUnique = K(unique, R.length)

What do we gain?

Properties being observed are kept in sync.

We just decide which properties to observe.

Embedding Observables into JSX

What happens?

const greetingsTarget = Atom("world")
...
<div>Hello, {greetingsTarget}!</div>

It crashes. React cannot render observables.

Lifted elements

const greetingsTarget = Atom("world")
...
<K.div>Hello, {greetingsTarget}!</K.div>
x

Continued

<K.input type="text"
         value={greetingsTarget}
         onChange={e => greetingsTarget.set(e.target.value)}/>
x

How?

JSX, e.g.

<div someProp="a value"><p>Child</p></div>

evaluates into a tree of objects, roughly

{ "type": "div",
  "props":
   { "someProp": "a value",
     "children":
      { "type": "p",
        "props": { "children": "Child" } } } }

and the props are passed to the React class.

The class of a lifted element

componentWillMount() {
   ... subscribe ...
}

componentWillUnmount() {
   ... dispose ...
}

render() {
  return this.state.rendered
}

Subscribe creates a stream to update rendered state.

Performance?

Everything can be made to work incrementally.

Mount and unmount take a little extra.

But then you only recompute changed VDOM.

Asymptotically better than recomputing all VDOM.

Never write another React class or shouldComponentUpdate.

Performance (continued)

Properties and streams everywhere.

Possibly thousands upon thousands.

Bacon is neither space nor time optimal. :(

Kefir uses significantly less (~5x).

With Kefir performance seems very competitive.

Lenses

Lenses

Let you declare a path to an element.

  • That you can then use to
    • view, and
    • update
    the element.
  • Partial lenses also allow one to
    • insert, and
    • delete
    the element.

Example

const data = {items: [{id: "a", value: 20}, {id: "b", value: 10}]}
const itemWith = id => L("items", L.find(R.whereEq({id})))

L.view(itemWith("a"), data)

L.set(L(itemWith("a"), "value"), 15, data)

L.delete(itemWith("a"), data)

Atom also supports lenses

atom.lens(partial-lens)

For example:

import L from "partial.lenses"
const names = Atom(["first"])
const first = names.lens(L.index(0))

Now you can, e.g.

first.set("Still first")

Best practises

Atoms should be roots

Don't overuse Atoms: leads to imperative spaghetti.

Use them for simple data-binding.

Setting the value of an atom in response to a change of an atom is a smell.

Remember: You can use other kinds of wiring!

But more complex wiring seems to be rarely needed.

Clearly separate meta model

Atoms and Observable embedding make it easy to have: component = 1 function.

But you really dont want to clump everything together.

Separate the model, meta-model, control.

It makes code conceptually clearer.

It makes the models more easily testable and usable.

Not cool, but calm2!

Don't overdo components

It is easy to go overboard with components.

Wrapping basic HTML elements, e.g. textarea or select, as components.

You end up making them difficult to customize.

Your components should do something substantial.

Does it have a non-trivial model?

Is it a combination of elements you use in lots of places?

What about...

Routing?

Http requests / IO?

Two-Way Binding

Issues raised in AngularJS: The Bad Parts:

Dynamic scoping and Parameter name based DI We use ordinary JavaScript with lexical scoping. Data is explicitly routed using lenses and properties. The digest loop Property updates are done incrementally by the underlying "FRP" library. Redefining terminology All concepts have been around for a long time (Observable, Property, Atom, Lens).

Questions?

Exercises

Add unit tests for the meta-model

The meta model is just a bunch of operations on model.

Add e.g. mocha to the project and tycittele a few tests.

Add homepage -link field

Make editable link component.

Look at TextInput.

When not editing, render as a link.

Add birthday field

Select some React datepicker component.

Bind it to the model.

Add filtering and paging

Limit the number of viewed items.

Create them as components.

That lets you navigate a list (prev, next, ...).

Add local storage and Undo

(I'll give the code for Undo.)

Add external storage

You will likely need to add Node (Express) to the project.

Bacon/Kefir+React+Atom+Lenses For Concise, Reactive UI Vesa Karvonen