Elmのupdate: Msg -> Model -> (Model, Cmd Msg)にあこがれてJavaScriptの状態管理用のライブラリ作った
作ったのはこれ。
動機
普段Reduxで非同期なことをやる時にredux-thunkミドルウェアを使っている。
で、次のようなことをするときにいつも悩む。
- 非同期にデータを取ってくるアクションで取得前にローディングマスクを表示し、取得完了後にローディングマスクを非表示にする。
- なんらかのメッセージ(完了したとかエラーになったとか)をToast的に表示して、数秒後に消す。
例えばローディングマスクのやつだと、だいたい次のように実装している。
import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; const fetchMessage = () => { return new Promise(resolve => { // サンプルなのでsetTimeoutでごまかし setTimeout(() => { resolve('hello'); }, 1000); }); }; const load = () => { return async dispatch => { dispatch({ type: 'prepare-for-loading' }); const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); }; }; const store = createStore((state = { msg: '', loading: false }, action) => { switch (action.type) { case 'prepare-for-loading': { return { ...state, loading: true }; } case 'loaded': { return { msg: action.payload, loading: false }; } default: { return state; } } }, applyMiddleware(thunk)); store.subscribe(() => { console.log(store.getState()); }); store.dispatch(load());
このとき気になるのが、load
の実装。
const load = () => { return async dispatch => { dispatch({ type: 'prepare-for-loading' }); const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); }; };
thunkなアクションとしてdispatchを受け取る関数を返し、非同期API実行の前後で2回dispatchしている。 これが1つのアクションで2つの仕事をしているように見えて、なんだかなーと思っていた。 テストを書くときも、この関数は2つアクションを返して、1つ目は...みたいになっていて、ますますなんだかなーと。
次のように中身を分離して使う側でそれぞれ呼び出すという手もあるが、これを並べて同じタイミングで実行するのか、
それとも1つ目のdispatchで変更されたStateのloading:true
が来たら2つ目を別のタイミングで実行するべきかなどで悩ましい。
store.dispatch({ type: 'prepare-for-loading'}); store.dispatch(() => { return async dispatch => { const msg = await fetchMessage(); dispatch({ type: 'loaded', payload: msg }); } });
そんな感じで悶々としていた。
Elmとの出会い
ReduxがElmを参考にしたという話は知っていたけど、特別触ってみたりはしていなかった。 ある日rebuild.fmのエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。
Elmアーキテクチャの簡単なやつを触って、update
のインターフェイスがupdate : Msg -> Model -> Model
な辺りまでは、
確かにReduxっぽい...みたいな印象だった。
その後、commandを使う辺りまで進んで、インターフェイスがupdate : Msg -> Model -> (Model, Cmd Msg)
になった時に、自分がやりたかったのこれだなーと思った。
ローディングのやつの例でElmだと次のようになると思う(実際は非同期取得の結果の成否とかもっとcase増えると思うけど)。
update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of PrepareForLoading -> ( { model | loading = True }, load ) Loaded payload -> ( { model | msg = payload }, Cmd.none )
で、JavaScriptでこんな感じにやるには...と考えて冒頭に貼ったcycloneというライブラリを作った。
cycloneを使った場合
Reduxがupdate<S, A>(state: S, action: A) => S
なのに対して、cycloneはupdate<S, A>(state: S, action: A) => [S, A]
というインターフェイスになっていて、更新された状態と次に実行するアクションをタプルで返すようにしてある。次に実行するアクションがない場合はnone
を返す。これはElmでのCmd.none
を意識した。
上のローディングマスクの例をcycloneで書き直すと次のようになる。
import { createStore, none } from '@ushiboy/cyclone'; const fetchMessage = () => { return new Promise(resolve => { setTimeout(() => { resolve('hello'); }, 1000); }); }; const prepareForLoading = () => ({ type: 'prepare-for-loading' }); const load = async () => { const msg = await fetchMessage(); return { type: 'loaded', payload: msg }; }; const store = createStore({ msg: '', loading: false }, (state, action) => { switch (action.type) { case 'prepare-for-loading': { return [ { ...state, loading: true }, load() ]; } case 'loaded': { return [ { msg: action.payload, loading: false }, none() ]; } default: { return [state, none()]; } } }); store.subscribe(() => { console.log(store.getState()); }); store.dispatch(prepareForLoading());
prepareForLoading
を実行すると、update
はloadingをtrueにし、次に実行するload
を返す。
storeは更新された状態を通知しつつ、次に実行すべきアクションを実行する。
load
の前に実行する関数の名前をどうするか、名前お悩み問題が増えたような気がするけど、
アクション的には1つのアクションで1つの仕事をするようになったので、概ね満足。
その他の機能
Reducer
ついでにつけた機能として、Reducerでの分割統治はできるようにしておこうということでcombine
とreducer
というAPIを用意した。
使用例は次の通り。
import { createStore, none, combine, reducer } from '@ushiboy/cyclone'; const store = createStore({ a: 0, b: 0, c: '' }, combine( reducer('a', (state, action) => { switch (action.type) { default: { return [state, none()]; } } }), reducer('b', (state, action) => { switch (action.type) { default: { return [state, none()]; } } }), reducer('c', [ 'a', 'b' ], (state, action, a, b) => { switch (action.type) { default: { return [state, none()]; } } }) ));
Reducerの定義はreducer
関数に対象とするStateのキー名とupdate関数を渡して行うようにした。それをcombine
関数で統合してひとつのupdate関数にする。
Reducerで分割統治すると、後々この状態はこっちでも参照したい...がでるので、reducer
関数の第2引数に参照したい他の状態のキー名リストを渡すとupdate関数の第3引数以降で受け取れるようにした(上の例だとc
のReducerでa
とb
の状態を参照している)。
Extra Argument
もうひとつ、redux-thunkのwithExtraArgument的な機能も足した。
createStore
の第3引数にアクション実行時に利用したいオブジェクトなどを設定すると、アクション側で受け取って使用できる。
これはWebAPIみたいな、テストの時はインターフェイス同じなモックに差し替えたいものを使うことを目的としている。
サンプルコードは次の通り。
const greet = () => async ({ sleep }) => { await sleep(1000); return { type: 'greet', payload: { msg: 'hello' } }; }; const store = createStore( { word: '', waiting: false }, (state, action) => { switch (action.type) { default: { return [state, none()]; } } }, { sleep(time) { return new Promise(resolve => { setTimeout(() => { resolve(); }, time); }); } } );
greet
アクションはcreateStoreの第3引数で指定したsleep
APIを持つオブジェクトを受け取って使用している。
まとめ
Elmの学習はupdateのインターフェイス以外にも参考になることが結構あったのでやってよかった。
作ったライブラリは、ひとまず意図した動きになっているけど、中身の実装でまだ迷っている部分があるので、 その辺はドッグフーディングしながら様子見。