preact
parket

preact + parket で軽量開発

Parket

parket

https://www.npmjs.com/package/parket

inspired by mobx-state-tree な状態管理ライブラリです。preact と併用することで、hyperappに迫る軽量化を実現しています(Small (~1.5KB))。initial commit が 2018/01/24 と、出来たてホヤホヤですね。

usage

リポジトリを眺めていれば特に難しいことは無いと思います。parket に view は含まれておらず、現状では preact と react のバインディングが含まれています。以下リポジトリの usage 引用。

usage.js
import model from 'parket';
// model returns a "constructor" function
const Person = model('Person', { // name is used internally for serialisation
  initial: () => ({
    firstname: null,
    lastname: null,
    nested: null,
  }),
  actions: state => ({
    setFirstName (first) {
      state.firstname = first; // no set state, no returns to merge, it's reactive™
    },
    setLastName (last) {
      state.lastname = last;
    },
    setNested (nested) {
      state.nested = nested;
    },
  }),
  views: state => ({
    fullname: () => `${state.firstname} ${state.lastname}`, // views are computed properties
  }),
});

views は Vue.js や Mobx の computed と等価のものですね。(是非 computed に改名して欲しい)

mittで動いている

https://www.npmjs.com/package/mitt

軽量 EventEmitter の mitt が Dependencies。値の変化をsubscribeするAPIがいくつか用意されている様です。console の内容を見ると、イベント名 や payload が乗っかってるのが分かります。

#counterA.onAction(console.log)
{name: "increment", path: "/counterA", args: Array(1)}

# counterA.onPatch(console.log)
{path: "/counterA/count", op: "replace", value: 1}

# counterA.onSnapshot(console.log)
{name: "counterA", count: 1, label: "counterA + counterB", calcVal: 0, __p_model: "Counter"}

これらは subscribe の wrapper で、戻り値を呼ぶことで unsubscribe 出来ます。

サービス層に乗せてみる

Untitled.gif

「状態管理にサービス層がないとやっていけない」と最近思っている筆者は、さっそくasync/await に乗せてみました。SingleStoreを作って、サービス起動して、View を render するいつもの構成です。

app.js
import 'babel-polyfill'
import { render } from 'preact'
import model from 'parket'
import Counter from './model'
import runService from './service'
import View from './view'

const store = model('Store', {
  initial: () => ({
    counterA: Counter({
      name: 'counterA',
      label: 'counterA + counterB',
      count: 0
    }),
    counterB: Counter({
      name: 'counterB',
      label: 'counterA x counterB',
      count: 1
    })
  })
})()

runService(store)

render(<View store={store} />, document.getElementById('app'))

model.js
import model from 'parket'

export default model('Counter', {
  initial: () => ({
    name: '',
    count: 0,
    label: '',
    calcVal: 0
  }),
  actions: state => ({
    increment () {
      state.count++
    },
    decrement () {
      state.count--
    },
    setCalcVal (calcVal) {
      state.calcVal = calcVal
    }
  })
})

service.js
export function change () {
  return Promise.race(
    [].slice.call(arguments).map(model => {
      return new Promise(resolve => {
        const unsubscribe = model.onPatch(event => {
          unsubscribe()
          resolve(event)
        })
      })
    })
  )
}

async function calculate ({ counterA, counterB }) {
  while (true) {
    counterA.setCalcVal(counterA.count + counterB.count)
    counterB.setCalcVal(counterA.count * counterB.count)
    await change(counterA, counterB)
  }
}

export default function runService (store) {
  const { counterA, counterB } = store
  calculate({ counterA, counterB })
}


view.js
import { Component } from 'preact'
import { connect, observe, Provider } from 'parket/preact'

function MyComponent ({ name, count, label, calcVal, increment, decrement }) {
  return (
    <div>
      <h1>{name}:{count}</h1>
      <p>{label} = {calcVal}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
    </div>
  )
}

@observe
class Panel extends Component {
  render ({ counter }) {
    return (
      <MyComponent
        name={counter.name}
        count={counter.count}
        label={counter.label}
        calcVal={counter.calcVal}
        increment={counter.increment}
        decrement={counter.decrement}
      />
    )
  }
}

@connect
class Container extends Component {
  render ({ store }) {
    return <div>{this.props.children}</div>
  }
}

const root = ({ store }) => (
  <Provider store={store}>
    <Container>
      <Panel counter={store.counterA} />
      <Panel counter={store.counterB} />
    </Container>
  </Provider>
)

export default root


難なくこなしてくれました。モデルが純粋で、魅力的 :sparkles:

sample: https://github.com/takefumi-yoshii/preact-parket-sandbox

Single source of truth(自説)

上記サンプルの app.js では Store を一つにしています。というのも、service.js を以下の様に変更すれば、ReduxAction と同じ様にonPatchStore()関数でグローバルイベントとしてキャッチすることができるためです。 Redux ユーザーにとっては、ピンとくる利点ではないでしょうか。

service.js
function onPatchStore (event) {
  // console.log(event) # do something
}

export default function runService (store) {
  const { counterA, counterB } = store
  calculate({ counterA, counterB })
  store.onPatch(onPatchStore) // New
}

LGTM :innocent:

MobXらしい簡単さと、イベント駆動が融合していて、v0.2.0ですがとても期待の高まるライブラリです。