ミルク色の記録

やったこと、やってみたこと

Elmのupdate: Msg -> Model -> (Model, Cmd Msg)にあこがれてJavaScriptの状態管理用のライブラリ作った

作ったのはこれ。

www.npmjs.com

動機

普段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のエピソードで取り上げられているのを聴いて、ちょっと興味が湧いたので勉強してみた。

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での分割統治はできるようにしておこうということでcombinereducerという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でabの状態を参照している)。

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引数で指定したsleepAPIを持つオブジェクトを受け取って使用している。

まとめ

Elmの学習はupdateのインターフェイス以外にも参考になることが結構あったのでやってよかった。

作ったライブラリは、ひとまず意図した動きになっているけど、中身の実装でまだ迷っている部分があるので、 その辺はドッグフーディングしながら様子見。