ReduxでReducerとInitialStateを分けるためのbetterCombineReducers

  • 42
    いいね
  • 3
    コメント

要約

ReduxのReducerは、特にcombineReducersを使うときには初期状態を含むように書く必要がある。それでは数学的に美しくないし、実際に都合の悪いケースもある。そこで、初期状態を別途与えることができるbetterCombineReducersを提案したい。それは下記に公開されており、利用することもできる。

GitHub: shinout/better-combine-reducers

導入

ReduxとDFA(決定性有限オートマトン)

Reducerの定義を見て欲しい。

Reducer<S, A> = (state: S, action: A) => S

Reducerは、StateとActionから、新しいStateを返す。

このようなシンプルで美しい設計は、離散数学の一分野である決定性有限オートマトン(DFA)にそのヒントを得ていると考えられる。その中で出てくる「状態遷移関数」こそがReducerだ。

DFAの考え方に従えば、状態機械というのは

  • 状態の集合
  • 入力記号の集合
  • 状態遷移関数
  • 開始状態
  • 受理状態の集合

によって定義できる。アプリケーションを状態とその遷移と捉えるReduxは、そのままDFAの概念にマッピングされる。(無限の状態を有限のクラスにまとめたり、受理状態を空集合と捉えたりなどがありそうだがここでは触れない)

DFAとReduxをマッピングすると下記のようになる。

  • 状態機械: Store
  • 状態: State
  • 入力記号: Action
  • 状態遷移関数: Reducer
  • 開始状態: InitialState
  • 受理状態: (受理の定義がないので、なし)
const reducer = (state, action) => state
const store = createStore(reducer, initialState)
store.dispatch(action)

コードもそのものを表せている。

ReduxはInitialStateをReducerの一部とみなしている

このようにDFAとReduxには共通点が多いように思われたが、ひとつ気になる点があった。
それが、ReduxではReducerがInitialStateを知っていることを推奨しているという点だ。
DFAの言葉でいうと状態遷移関数が開始状態を知っているという意味になる。
DFAでは、状態機械とは上に挙げた独立5変数によってできていると捉えていたが、ReduxはInitialStateをReducerの一部と捉えているようだ。

const reducer = (state = initialState, action) => {
    switch (action.type) {
    default: break
    }
    return state
}

こんな感じで、stateundefinedが入ったときはinitialStateを返すように実装することを推奨しているのだ (See http://redux.js.org/docs/basics/Reducers.html )。

combineReducersすると、ReducerにInitialStateを書かなくてはならない

Reduxは、複数のReducerを束ねるcombineReducersという関数を提供しているが、
これを使うと、いよいよReducerがInitialStateを知っていることは、推奨ではなく必須となる。

ReduxのcombineReducersの仕組みについて理解したいマンという良記事によれば、combineReducersは、その過程でReducerとしての要件を満たしているかを確認する処理assertReducerSanityが呼ばれるという。そこではstateにundefinedを入れて、非nullな値が返るかどうかを検証しているのだ。その検証が通らなければ、combineReducersは失敗する。よって、否が応でも初期状態をReducerに書くことになるのであった。

なぜReducerにInitialStateを書きたくないのか

これは、DFAとの対応が崩れてしまうという、理論上の話にとどまらない。
ここでは、次のよくありそうな要件のアプリを考える。

  • アプリ終了時に、Reduxの最後のStateをJSONとして保存
  • 再開時にそれをInitialStateとする

このようにInitialStateが状況に応じて変わることがあるアプリだと、ReducerにInitialStateを書くことで不都合が生じる。
初期状態は毎回変わるので、初期状態がReducerに含まれていたらいけない。

初期状態を束縛するためにcreateReducerすることで対応可能だが、そのままやると見た目もひどい。

const createReducer = initialState => (state, action) => {
    if (state == null) return initialState
}

実際にはcombineReducersの事も考えると、各キーごとにこの作業をするので、より汚くなる。

betterCombineReducersの提案

そこで提案するのが、下記のようにつかえる関数だ。

const reducer = betterCombineReducers(original)(reducers, initialState)

複数のReducerと、それと同じkeyをもったInitialStateを与えると、combineしてくれる。
originalは、Reduxの提供する、元のcombineReducersである。

コード

const betterCombineReducers = (original) => (reducers, initialState) => {
    const newReducers = {}

    Object.keys(reducers).forEach(key => {
        const fn = reducers[key]
        const defaultState = initialState[key]
        newReducers[key] = (state, action) => {
            if (state == null) return defaultState
            return fn(state, action)
        }
    })
    return original(newReducers)
}

各キーごとに初期状態を取り出して、デフォルト値としている。
なお実際のコードはエラーハンドリングやIE8のことも考えているので多少冗長である。

まとめ

Reduxの初期状態と遷移関数は極力分けて管理するほうが美しさも実益もあると考えられる。
それを助けるためのbetterCombineReducer、お試しを。