こんにちは、ほそ道です。
ここ最近、大人数でReactNativeを扱う機会があり、そのときに皆が苦戦していた傾向として
「reactとreduxの組合せ、もっと言えばreact-reduxというモジュールのボイラープレート(おまじない的な部分)が良くわからないのではないか?」
と思って色々サンプルを作ってコミュニケーションしてみた所、我が意を得たり!という反応が得られたので、その時に話したことやネタを展開してみようと思います。
シンプルなものから段階的に見ていくほうが理解を深めていきやすいのでは、と思い2段階ステップを踏んでいきます。
結論、Step1よりもStep2の方がベターケースであると考えています。
また、文中で幾つかの考え方を「ノイズ」と表現していますが、あくまで最小限のカタチを考える上で「無くても良い」という意味でノイズと言っているだけなので、「ない方がいい」「使ってはならぬ」とは特に思っておりませんので、前提としてご了承いただければと思います。
それぞれ、ひとつのアプローチのカタチとして捉えていただければ幸いでございます。
作るもの
同期的アクション、非同期的アクションそれぞれを作ります。
やっている事はクリックイベントで状態のboolean値をトグルするだけです。
サンプル(ブランチでstep1/2を分けていて、js版とtypescript版を作っています)
Step1: react-reduxを使わないカタチ
まずは、ひとつの呪縛ではないかと感じられた点。
react-redux使わないといけないのではないか?
→使わずとも動くものは作れます。
で、Reduxが単体で提供しているカタチを意識して、それを踏襲することをStep1とします。
reduxのベーシックなカタチに当てはめる
下記の駄図1を中心に見ていきたいと思います。図のviewは特にreactであることを特定していません。
subscribe(f)とpublishの間にうっすい赤線が引いてありますが、状態変更時に登録済みコールバックがコールされることをなにか示してみただけなのであまり気にしないでください(笑)。
- 0: viewから
store.subscribe
をコールし、変更通知を受け取れるようにしておく - 1: 何かイベントが発生し、状態の更新を行う必要が発生したら、
store.dispatch
をコールし、storeに変更内容(action)を伝える - 2: reducerの中でactionをハンドリングして状態が更新される
- 3: 状態が更新されるとsubscribeの引数として登録していたコールバックが呼び出される
- 4:
store.getState
をコールし更新された状態を受理する
上記の流れをview=reduxとして実装してみます。
reactでこのパターンを適用した場合、状態はviewとreducer両方に保持される事になります。
理解の為の第一段階として、まずはいきますので、react with reduxに最適化を進めるのは次のStep2で行っていきます。
実装してみる
流れをぶった切るようですが、まずはactionsから見ていきます。
viewとreducerをつなぐactionという存在の解釈は、前提として認識しておくと話が進めやすいと思ったからです。
export function syncAction(x) {
const nextX = !x;
return {
type: 'SYNC_ACTION',
x: nextX
}
}
function async(y) {
return new Promise((resolve) => {
resolve(!y);
})
}
export async function asyncAction(y) {
const nextY = await async(y);
return {
type: 'ASYNC_ACTION',
y: nextY
};
}
actionsは更新計算を行い、オブジェクトに包んで返します。
最小限のカタチを分析するに辺り、「ActionCreators」という概念は排除しています。
ActionCreatorsという概念の登場は必須であるというノイズは剥がしておきたいなと思いました。
必要なのはアクションというオブジェクトをreducerに渡すことである、という考えに基づけば、
スタート地点として、Actionオブジェクトを返すactionという関数があれば十分であるとを考えています。
次に、viewを見ていきます。
import * as React from 'react'
import * as ReactDOM from 'react-dom';
import {store} from './Store'
import {asyncAction, syncAction} from './Actions'
import {defaultState} from "./Reducer";
class MyView extends React.Component {
constructor() {
super();
this.state = defaultState;
}
componentDidMount() {
store.subscribe(() => {
this.setState(store.getState());
})
}
render() {
return (
<div>
<div onClick={() => store.dispatch(syncAction(this.state.x))}>
{`sync value ${String(this.state.x)}`}
</div>
<div onClick={async () => store.dispatch(await asyncAction(this.state.y))}>
{`async value ${String(this.state.y)}`}
</div>
</div>
);
}
}
ReactDOM.render(<MyView/>, document.getElementById('contents'));
駄図1のフローに即したカタチになっています。
componentDidMount
でstore.subscribe
をコールして変更通知の予約。
イベントが発生したらstore.dispatch
をコールして変更をstoreに依頼。
変更通知を受け取ったらstore.getState
をコールして自身が持つ状態を更新。
駄図1と同様のカタチになっております。
非同期なactionの呼び出し方としては、asyncな関数をawaitでaction値を取得してからdispatchに渡しています。
ここにも非同期なactionの生成にはmiddlewareを使わねばならないというノイズがあったように思いますので、
このようなカタチでもシンプルに表現可能であるというのが言いたかったことです。
最後にreducerも見ていきます。
export const defaultState = {x: true, y: true};
export const AppReducer = ((state = defaultState, action) => {
switch (action.type) {
case 'SYNC_ACTION': {
return Object.assign({}, state, {x: action.x});
}
case 'ASYNC_ACTION': {
return Object.assign({}, state, {y: action.y});
}
default: {
return defaultState;
}
}
});
reducerはactionに包まれた値をもって、状態の更新だけを行っています。
Step1は以上です。
細部を見たい、実際に動かしたい場合はサンプルのブランチstep1_jsまたはstep1_tsを御覧くださいませ。
Step1考察
まず、react-reduxモジュールだけを体験し、苦しんだ人がこのステップを踏んだ場合、
だいぶ全体の構成・フローがシンプルに感じられ、理解が出来るのではないでしょうか?
そして、Step1から2に進むに辺り、改善したい課題は下記の2点です。
・状態がreducerとview両方に保持されている
・viewのライフサイクルメソッドハンドリングがあるためにviewの見通しが悪くなる。
ほそ道は、このあたりを改善するためにreact-reduxというモジュールは生まれたのだと思っています。文献などを探ったわけではないですが。
さらにsetState問題をクリアするに辺り、下記のような内容も参考になると思います。
Container Component
Functional Component
Stap2: react-reduxに乗っかったカタチ
さて、react-reduxを登場させます。
フローを考える
今度は下記の駄図2を中心に見ていきたいと思います。
駄図2は駄図1をベースに、新規の概念ややり取りを青字で示しています。
- 前提としてviewはprops引数を適用すれば、ReactComponentを返す関数となっており、内部状態は持たないようにStep1から変わっています。
- 0: Providerの子要素として
connect
で生成されたコンポーネントを指定する - 1: 何かイベントが発生し、状態の更新を行う必要が発生したら、
dispatch
をコールし、storeに変更内容(action)を伝える ★変更なし - 2: reducerの中でactionをハンドリングして状態が更新される ★変更なし
- 3: 状態が更新されるとconnectに部分適用で登録していた変換コールバック関数がpropsを生成し、関数として定義されたviewの引数に入ってくる
実装してみる
まず、actionsとreducerはStep1から変更ありません。
駄図2上も1,2のステップは変更ありませんし、ここに変化がないのは自然であることのように思います。
では、改善できたかの結論としてのviewを見ていきます。
import * as React from 'react'
import {asyncAction, syncAction} from './Actions'
export const MyView = ({state, dispatch}) => {
return (
<div>
<div onClick={() => dispatch(syncAction(state.x))}>
{`sync value ${String(state.x)}`}
</div>
<div onClick={async () => dispatch(await asyncAction(state.y))}>
{`async value ${String(state.y)}`}
</div>
</div>
);
};
subscribe
、getState
のコールが無くなりました。
setState
で内部状態を更新しなければならないような構成から開放されました。
Step1の課題は解決されたように思います。
それでは、このカラクリを知るためにreact-reduxの道具たちの使いっぷりを見ていきましょう。
import * as React from 'react'
import * as Redux from 'redux'
import * as ReactDOM from 'react-dom';
import {connect, Provider} from 'react-redux'
import {AppReducer} from "./Reducer";
import {MyView} from "./MyView";
export const appStore = Redux.createStore(AppReducer);
const view = (props) =>
<MyView state={props.state} dispatch={props.dispatch}/>;
const App = connect(s => ({state: s}))(view);
ReactDOM.render(
<Provider store={appStore}>
<App/>
</Provider>,
document.getElementById('contents')
);
connect
の中ではボイラープレート上、mapDispatchToProps
と呼ばれる関数の設置を省略しました。
このようにすることでstoreのdispatch
関数がpropsに流れてくるようです。
ここは、bindActionCreators
×actionCreators
を使わなければならないというノイズも剥がすことが出来ればなと思っているポイントです。
actionCreatorsを設置すると、いわゆるreduxフロー図のComponent->Action->Reducerという流れが成立しているように見えやすい、
というメリットはあるのではないかと思うのですが、この概念を省略することでactionは単なるActionを生成する関数というシンプルさを生むことにもなるのかなと。
この辺、最終的にどのような構成を選び取るかは好き好きで良いのではないかと思います。
あらためてサンプル(ブランチでstep1/2を分けていて、js版とtypescript版を作っています)を貼っておきます。TypeScript版は型の解決がなんだかややこしかったですが、コンパイラの静的チェックは通る状態になっております。
今回は以上です。
react-reduxの理解に苦しんでいた方にとって、少しでも助けになるものであれば幸いです。
また、独自解釈をしている部分に関して、「それはこうだよ」というフィードバックをいただければありがたいです。