Redux には多くの利点がありますが、避けられない課題があります。競合する状態管理ライブラリの比較を以下に記しました。あくまで個人的な三者相対表ですが、世論もほぼこれに近いのではないでしょうか。
Redux | MobX | Vuex | |
---|---|---|---|
書き味 | × | ○ | △ |
スケール耐性 | ○ | × | △ |
型相性 | △ | ○ | × |
非属人性 | △ | × | ○ |
ライブラリ薄さ | ○ | △ | × |
継承 (mixin) | × | ○ | △ |
書き味の悪さは致命的なレベルだと思っています。この課題を解決するべく、ヒントを求めて様々な状態管理ライブラリを試しました。unistore や parket、Vuex、いずれもイベント駆動ですが、そこには書き味醸成のための共通点があることに気づきます。
**それは、状態を変更する手続きが少ないということです。**それらにインスパイアされた Reduxヘルパーが redux-aggregate です。考案にあたり気をつけたのは以下の点です。
- 既存のエコシステムを侵害しない
- 関数型で完結させる
- TypeScript による推論
- 0 dependencies で薄いこと
なお、これは Redux を扱う上でのヘルパー(非middleware)のため、Redux の基本的な理解は予め必要です。
📝 コードの紹介
**【Before】**今までは「+1」のために、これだけのコードが必要でした。
const initialState = { count: 0 }
const types = { INCREMENT: 'COUNTER_INCREMENT' }
const increment = () => ({ type: types.INCREMENT })
function reducer(state = initialState, action) {
switch (action.type) {
case types.INCREMENT:
return { ...state, count: state.count + 1 }
default:
return state
}
}
**【After】**redux-aggregate を利用した場合の等価コードです。
const initialState = { count: 0 }
const increment = state => ({ ...state, count: state.count + 1 })
私たちが書く必要があるのは、これだけだと思いませんか? この状態を変更する純関数を mutation と呼んでいます(既視感)。mutation の集合からボイラープレートを生成するのが、redux-aggregate の役割です。保守一貫性は自動化に任せ、開発者はコードが届ける価値に集中力を注ぎましょう。
**型ダメでしょ?とお考えの方、もう少々お付き合い下さい。**ここでは可読性の都合で省いていますが、TypeScript2.8 から追加された新機能でがっちり推論が効く仕様になっています。
❓ Mutations とは
上記の通り、Mutation は状態を変更する純関数です。Reducer を分解したものであり、Mutations にまとめられた key名を起点として、内部でボイラープレートを生成しています。createAggregate
がそれで、戻り値にボイラープレートが含まれています。
const {
types, // ActionTypes
creators, // ActionCreators
reducerFactory // Reducer 生成関数
} = createAggregate(mutations, 'counter/')
第一引数には、Mutations、第ニ引数には名前空間を与えます。
const increment = state => ({ ...state, count: state.count + 1 })
const decrement = state => ({ ...state, count: state.count - 1 })
const mutations = { increment, decrement }
export { mutations }
名前空間がコンフリクトした場合エラーを投げる様にしているので、これ起因の事故は無くなります。名前空間は一意の文字列であれば何でも構いません。
const Counter1 = createAggregate(mutations, 'counter/')
const Counter2 = createAggregate(mutations, 'counter/') // throw Error!
この様なアクションが発生します。
この書式に問題があり、見慣れたものを定義したい場合は以下の通りにすれば良いだけです。
const INCREMENT = state => ({ ...state, count: state.count + 1 })
const mutations = { INCREMENT }
const COUNTER = createAggregate(mutations, 'COUNTER_')
// COUNTER_INCREMENT
この createAggregate 関数ですが、ボイラープレートを生成するだけではなく、同時に後述の TypeScript による推論型導出を行なっています。
🔥 TypeScript v2.8 新機能の活用
純関数に分解したことにより、型との相性が良くなっています。Type inference in conditional types により、mutation関数の payload型 を ActionCreator に mapping。たったひとつの関数を1度中継するだけで、隅々まで推論導出可能なことを示しています。
従来の Redux は型指定にも苦労しましたが、このキャストも不用となるため、開発体験が格段に向上します。
【詳細記事】 TypeScript2.8 Conditional Types 活用事例
✅ immutable な変更を楽にしたい
Mutation は Reducer に記述していたものと等価です。第一引数に扱う状態を、第二引数に payload をとる様に定義します。ObjectSpread による immutable な変更も辛さの一端かもしれないので、そういった方は immer をここに挟めば良いと思います。このライブラリも薄いのでお勧めです(任意)。
const state = {
a: { b: { c: 'c' } }
}
function setC(state, payload) {
return {
...state, a: {
...state.a, b: {
...state.a.b, c: payload
}
}
}
}
import immer from 'immer'
const state = {
a: { b: { c: 'c' } }
}
const setC = (state, payload) => immer(state, _state => {
_state.a.b.c = payload
})
さらに、サブモジュールの redux-aggregate-immer を利用すると、mutable mutations を一括で immutable mutations に変換することができます。
import { wrapImmer } from 'redux-aggregate-immer'
// ______________________________________________________
//
// @ mutable mutations for wrapImmer
const mutations = {
increment(state) {
state.count++
},
decrement(state) {
state.count--
},
setNestedValue(state, value) {
state.a.b.c = value
}
}
// convert to immutable mutations
export const CounterMT = wrapImmer(mutations)
注意点としては、ObjectSpread と比較して immer は速度的に分が悪いです。利用する際はこのトレードオフも認識しましょう。この mutations map、どこかで見覚えがありませんか?
✅ 状態にモデルの振る舞いを与える
普通の Redux は抱えている状態を写し出すだけなので、要件によってはこれでは物足りなくなります。状態を利用し、必要な値を出力する関数群を定義することで、表現力がぐっと上がります。これを Queries と呼んでいます。
export const state = {
name: 'counter'
count: 0
}
// ___________________________
//
// @ Queries
function label(state) {
return `${state.name} count: ${state.count}`
}
function expo2(state) {
return state.count ** 2
}
export const queries = { label, expo2 }
**状態はモデルとしての振る舞いを持ち、他ライブラリの computed と同じ働きをします。**SFC の純度を上げるために、Queries は Container 上での利用を推奨しています。
import { queries } from 'path/to/counter'
const mapState = state => ({
name: state.name,
label: queries.label(state),
expo2: queries.expo2(state)
})
export const Container = connect(
storeState => mapState(storeState.counter)
)(props => <Component {...props} />)
Queries は redux-aggregate の API とは一切関わりがありません。また、このままでは再計算が頻繁に発生しますので、パフォーマンスが気になる方は reselect を中継する検討をしてください。
❓ 非同期処理は
middleware には関与していません。生成された ActionCreators / ActionTypes があるので、それを利用するだけです。redux-thunk や redux-saga、redux-observable など、使い慣れたものをご自由にどうぞ。上記と同様にexample を置いています。
❗️注意:Redux 思想との相違
こちらの記事を公開後、ご指摘がありましたので追記です。redux-aggregate が提供している「手軽さ」の トレードオフとして、Action / Reducer が多対一に制約されます。類似の Redux ヘルパーに対する issue として、Redux 作者の Dan はこの「多対一はRedux の柔軟さを損なう」と警告をしています。Action と Reducer は疎結合であり、分断された Reducer はどんな Action でも捉えることが可能で、制約はないということを利点として述べています。これを利用することで Reduxの思想から少し外れてしまうことをご理解ください。
v2 で many-to-many に対応しました。breaking change はありません。詳細を別記事に投稿しています。
ActionCreator の利用について
ActionCreator は主に以下の3種類に分類され、redux-aggregate が提供している ActionCreator は 同期ActionCreator であり、それを掌握しています。
- 同期 ActionCreator
- マッピング ActionCreator
- 非同期 ActionCreator
非同期 / マッピング ActionCreator にコードを凝集し抽象化することは、React コードのポータビリティから依然として必要です。以下の様な mutation から得られる ActionCreator の利用例を記します。
function setCount (state, { amount }) {
return { ...state, count: amount }
}
const mutations = { setCount }
const Counter = createAggregate(mutations, 'counter/')
これを利用するにあたり、以下の方法があります。
- Container で mapDispatchToProps
- マッピング ActionCreator で 処理を凝集する
- ReduxThunk を利用する
【Container で mapDispatchToProps】
Container で props に渡す名称を変更するパターンです。これが最も利用し易く、一番手軽な方法です。
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{ handleInputChange: Counter.creators.setCount },
dispatch
)
【マッピング ActionCreator で 処理を凝集する】
パラメーターを payload に変換する処理を、マッピング ActionCreator として用意することも場合によっては必要です。
function handleInputChange (event) {
const { value } = event.target
return Counter.creators.setCount({ amount: someProc(value) })
}
const mapDispatchToProps = (dispatch) =>
bindActionCreators({ handleInputChange }, dispatch)
【ReduxThunk を利用する】
function handleInputChange (event) {
return (dispatch, getState) => {
const { value } = event.target
someAPI('/path/to/endpoint', value).then(({ amount }) => {
dispatch(Counter.creators.setCount({ amount }))
})
}
}
const mapDispatchToProps = (dispatch) =>
bindActionCreators({ handleInputChange }, dispatch)
Counter.creators.setCount
を直接 React に props として渡すことは避けてください。handleInputChange
という抽象を利用することで、コード耐性が異なることが上記の例で分かるかと思います。
redux-aggregate が提供している ActionCreator はあくまで 同期的変更手続きを補佐するもの であることを理解したうえで、ご利用いただければと思います。