Redux の不満
Fluxの実装であるReduxの不満点のうちの1つとして、Reducerの扱いがある。もちろんReducerの考え方とそれによるStoreの状態管理、およびcombineReducers
による状態の分割統治についてはまあよいのだけれども、Reducerには同期的な状態変化しか扱えない(扱わない)という制約がある。得てして実際のアプリではモックで同期処理で行っていたことでがいつの間にか非同期の処理となったりすることもあり、その場合Reducerで上手くやってたことでもAction Creatorの方に移動しなきゃいけなくなったりする。
Action Creatorsでは現在のState情報を見るのにはgetState()
といきなりStateツリー全体にアクセスすることになる。Reducerではうまくできていた分割統治がここでは厳しくなる。なんとかMiddlewareで工夫できるのかもしれないけれども、結局それが最初から組み込まれてないというのはやっぱりムダなことである。
そして生成されるFatなAction Creatorsのコード。一方Reducerの方は、ただstateに値を設定しているだけのVery Thinなコード群。なんかこれ意味があるんだろうか。
問題は、ReduxではReducerは非同期を扱わないよ、Predictable命!とキリッとやってるからであって、まあそれってそんなにおいしかったんだろうか、という気持ちが拭えない。
Flumptとかみて、やっぱりおんなじこと思ってる人はいるもんだと思った。ただ、ReduxのReducer自体が嫌いなわけではないし、自分としては単純にReducerが非同期の状態変化をちゃんとあつかってくれればとりあえずはいいのだ。
Flumptでは関数キューとか頑張ってるみたいだけど、こういうの非同期の処理を適当にやろうとしたらRxがやっぱりいちばんパワフルだ。生のRxをつかってアプリ組むのは結構シビアだしメンテナンスきつそうだけど、Rx上で組み立てた仕組みを他のものに流用するのはまあありな気がしている。
Rxdux = Redux + 非同期Reducer
ということでこのような実装を最近あげた。
要はReducerの実行時に、即座に次のStateを返さずにPromiseなどの非同期な情報を返すことも許容したReduxのクローン、とおもってもらえればよい。これにより、Reducerでできることがかなり多くなり、Action Creatorsが肥大化することを防止できる。
function fruits(state = [], action) {
switch (action.type) {
case 'FETCH_FRUITS':
return new Promise((resolve, reject) => {
fetchFruits((err, records) => {
if (err) { return reject(err); }
resolve(records);
});
}
default:
return state;
}
}
Promiseはわかりやすい例だけれど、非同期の処理として他にもRxのObservableやGenerator Functionなどをサポートしている。Promiseの状態変化は遅れて1つやってくるだけだけど、ObservableやThunk Function, Generator Functionの場合は複数個の状態変化をストリームとして検知できる。
例えば、データの読み込み時にLoadingインジケータを最初に出して、非同期で値の読み込みが完了したら消す、とかの場合、こうする。
const initState = { loading: false, records: [] };
function fruits(state = initState, action) {
switch (action.type) {
case 'FETCH_FRUITS':
return (next, error, complete) => {
next({ ...state, loading: true });
fetchFruits((err, fruits) => {
if (err) { return error(err); }
next({ records, loading: false });
complete();
});
};
default:
return state;
}
}
Reducerの合成についてもReduxと同等のcombineReducers
を用意している。合成されたReducerは1つの非同期Reducerとして利用できる。
import { combineReducers } from 'rxdux';
import fruits = require('./fruits');
import animals = require('./animals');
export default combineReducers({ fruits, animals });
最終的にReducerからStoreを作るのも、Reduxとほぼおなじ、createStore
を用意している。インターフェースは互換になっているので、作られたStoreはそのままreact-redux
などを使ってReact Componentにbindできる。
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider, connect } from 'react-redux';
import { createStore } from 'rxdux';
function wait(n) {
return new Promise((resolve) => setTimeout(() => resolve(), n));
}
function counter(state = 0, action) {
switch (action.type) {
case 'ADD':
return function*() {
for (var i = 0; i < action.value; i++) {
state = yield wait(100).then(() => state + i);
}
}
default:
return state;
}
}
const initState = {};
const store = createStore(counter, initState);
@connect(
(state) => ({ counter: state })
)
class MyComponent extends React.Component {
render() {
const { counter, dispatch } = this.props;
return (
<div>
<div>count: { counter }</div>
<button onClick={ () => dispatch({ type: 'INCREMENT', value: 5 }) }>+5</button>
<button onClick={ () => dispatch({ type: 'INCREMENT', value: 10 }) }>+10</button>
</div>
);
}
}
class App extends React.Component {
render() {
return (
<Provider store={ store }>
<MyComponent />
</Provider>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));
制限事項
Reduxの既存のMiddlewareで、Action後のState情報にアクセスするものについては(redux-logger
など)、Reduce処理の実行後にStateは確定していないので、おそらくワークしない。非同期処理も前提としたMiddlewareの仕組みについては別途特別に用意する必要があるかもしれない
Actionでの状態の変更は完全にシリアライズされて実行されるので状態競合することはない。あるAcitonから発生した状態変更のストリームが終了するまで次のActionはブロックされる。つまりReducerは無限に続くObservableを返してはいけない(かならずCompleteされなければいけない)。