FROM IMV TO MVI

CYCLE STATE MADE EASY

Me

Software developer at Reaktor

 

@milankinen at GitHub and Twitter

A cyclist

STATE MANAGEMENT IN CYCLE

The Classical Way

Intent-Model-View

function Counter({DOM, initial$ = O.just(0)}) {
  const mod$ = O.merge(
    DOM.select(".dec").events("click").map(ev => state => state - 1),
    DOM.select(".inc").events("click").map(ev => state => state + 1)
  )
  const count$ = initial$.first().concat(mod$).scan((state, mod) => mod(state)).shareReplay(1)

  return {
    DOM: count$.map(count =>
      h("div", [
        h("button.inc", "++"),
        h("button.dec", "--"),
        h("p", "Counter: " + count)
      ])
    ),
    value$: count$
  }
}

Intent

Model

View

  • Intent first (no dependecies)

  • Model depends on Intent

  • View depends on Model

But what if Intents depend on Model?

a.k.a. "advanced" (=real life) example

function main({DOM}) {
  const stateModProxy$ = new Rx.Subject()
  const state$ = stateModProxy$
    .startWith({a: 0, b: 0})
    .scan((state, mod) => mod(state))
    .shareReplay(1)

  const a = state$.map(s => isolate(Counter, "a")({DOM, initial$: O.just(s.a)})).shareReplay(1)
  const b = state$.map(s => isolate(Counter, "b")({DOM, initial$: O.just(s.b)})).shareReplay(1)

  const resetMod$ = DOM.select(".reset").events("click").map(() => () => ({a: 0, b: 0}))
  const aChangeMod$ = a.map(s => s.value$.skip(1).first()).switch()
    .map(val => state => ({...state, a: val}))
  const bChangeMod$ = b.map(s => s.value$.skip(1).first()).switch()
    .map(val => state => ({...state, b: val}))

  const vdom$ = O.combineLatest(state$,
    a.map(s => s.DOM).switch(),
    b.map(s => s.DOM).switch(),
    (state, a, b) =>
      h("div", [
        a, b,
        h("hr"),
        h("h2", `Total: ${state.a + state.b}`),
        h("button.reset", "Reset")
      ]))

  O.merge(resetMod$, aChangeMod$, bChangeMod$).subscribe(stateModProxy$)
  return {DOM: vdom$}
}

POTENTIAL MEMORY LEAK!!

Intent

Model

View

"But it does not have to be like that"

State modification is a side effect

Let's put the state to a driver!

The Stanga way

Model-View-Intent

import {Model} from "stanga"

run(main, {
  M: Model(0),
  DOM: ...
})

function main({DOM, M}) {
  const state$ = M
  const vdom$ = state$.map(counter => h("div", [
    h("h1", `Counter value is ${counter}`),
    h("button.inc", "++"),
    h("button.dec", "--")
  ]))
  const mod$ = O.merge(
    DOM.select(".inc").events("click").map(() => state => state + 1)
    DOM.select(".dec").events("click").map(() => state => state - 1)
  )
  return {
    DOM: vdom$,
    M: M.mod(mod$)
  }
}

Intent

Model

View

  • Model first, state comes from driver

  • View depends on Model

  • Intent returns mods to driver via sink

import {Model} from "stanga"

run(main, {
  M: Model({a: 0, b: 0}),
  DOM: ...
})

function main({DOM, M}) {
  const state$ = M
  const a$ = state$.lens("a")
  const b$ = state$.lens("b")
  const a = isolate(Counter)({DOM, M: a$})
  const b = isolate(Counter)({DOM, M: b$})
  const vdom$ = O.combineLatest(state$, a.DOM, b.DOM, (state, a, b) =>
    h("div", [
      a, b,
      h("hr"),
      h("h2", `Total: ${state.a + state.b}`),
      h("button.reset", "Reset")
    ]))
  const mod$ = O.merge(
    DOM.select(".reset").events("click").map(() => () => ({a: 0, b: 0})),
    a.M,
    b.M
  )
  return {
    DOM: vdom$,
    M: mod$
  }
}

Intent

Model

View

typeof a$ === typeof M

Some cool stuff MVI+lenses enable

Truly composable components by default

Advanced list processing

export default function main({DOM, M}) {
  const counters$ = M
  const childSinks$ = counters$.liftListById((id, counter$) =>
    isolate(Counter, `counter-${id}`)({DOM, M: counter$.lens("val")}))

  const childVTrees$ = flatCombine(childSinks$, "DOM").DOM
  const vdom$ = O.combineLatest(counters$, childVTrees$, (counters, children) =>
    h("div", [
      h("ul", children.map(child => h("li", [child]))),
      h("hr"),
      h("h2", `Avg: ${avg(counters.map(c => c.val)).toFixed(2)}`),
      h("button.reset", "Reset"),
      h("button.add", "Add counter")
    ]))

  const childMods$ = flatMerge(childSinks$, "M").M
  const resetMod$ = DOM.select(".reset")
    .events("click")
    .map(() => counters => counters.map(c => ({...c, val: 0})))
  const appendMod$ = DOM.select(".add")
    .events("click")
    .map(() => counters => [...counters, {id: nextId(), val: 0}])

  return {
    DOM: vdom$,
    M: O.merge(M.mod(resetMod$), M.mod(appendMod$), childMods$)
  }
}

Simple state pre-/post-processing

export default function main({DOM, M}) {
  const {value$: counters$, canUndo$, canRedo$, mods: {undo$, redo$}} = undoable(M)

  const list = CounterList({DOM, M: counters$})
  const vdom$ = O.combineLatest(list.DOM, canUndo$, canRedo$,
    (listVDom, canUndo, canRedo) =>
      h("div", [
        h("button.undo", {disabled: !canUndo}, "Undo"),
        h("button.redo", {disabled: !canRedo}, "Redo"),
        h("hr"),
        listVDom
      ]))

  const mod$ = O.merge(
    undo$.sample(DOM.select(".undo").events("click")),
    redo$.sample(DOM.select(".redo").events("click"))
  )

  return {
    DOM: vdom$,
    M: O.merge(mod$, list.M)
  }
}

What next?

Thanks!

Btw, want to join to Stanga Club?

We are hiring in NYC, Tokyo and Helsinki 

reaktor.com/careers

Made with Slides.com