react+redux+react-router-redux+redux-sagaの環境を自分なりに使いやすくしていく
今回の話
前回、redux-sagaを使えるようにしてみた。
使えたけどsource fileの構成があまりよろしくない。今後作業して行くには不便なので綺麗にしてみよう、という話。
考え中
とりあえずStoreとActionはServerに対して固定で決まる部分と、Containerに対して決まる部分とがあるんだよなあ。
で、それをうまくまとめるのがReducerなんだけど、Reducerによって分割して書いてもあとでmergeできるとわかっている分かえってその、見通しが悪い。
見通しが悪くてもいいっちゃいいんだけど。
いやよくないな。
んー。でもContainerのmapStateToPropsとmapDispatchToPropsはreduxのconnectでComponentとつながないといけないから外しようがないのか。
やっぱりredux-sagaをうまく外に出して、redux-sagaというよりServer APIに対するActionとStoreと、Containerに対するActionとStoreのつなぎ込みをいかにうまく実現するか、というところが要点だなあ。
いっそheader fileでも作る?
とりあえずActionを分離させてみようか?
新たに/src/global/action/
以下を作成。
./src ├── components │ ├── GetForm │ │ ├── FormApp.jsx │ │ ├── ResultDisplay.jsx │ │ └── index.jsx │ ├── HelloWorld │ │ └── index.jsx │ ├── ReflectForm │ │ ├── FormApp.jsx │ │ ├── FormDisplay.jsx │ │ ├── FormInput.jsx │ │ └── index.jsx │ └── RouteTest │ └── index.jsx ├── global │ └── action │ ├── index.js │ └── testjson.js ├── pages │ ├── base.jsx │ └── routing.jsx └── root └── test.jsx
/src/global/action/index.jsx
にSaga(=redux-sagaのreducerみたいなもの)を定義して、それ以外のfileは呼び出されるActionを定義する。
/src/global/action/index.jsx
import { takeEvery } from 'redux-saga/effects' import TestJSON from './testjson' export default function* Saga() { yield takeEvery("TEST_FETCH_REQUESTED", TestJSON); }
/src/global/action/testjson.js
function getTestJSON(url){ return fetch(url) .then(res => { if(res.ok){ return res.json(); }else{ throw new Error(`Failed to fetch "${res.url}". ${res.status} ${res.statusText}`); } }) .then(payload => ([payload, undefined])) .catch(error => ([undefined, error])); } export default function* TestJSON(action) { const [payload,error] = yield call(getTestJSON, action.url); if(payload && error === undefined){ yield put({type: "TEST_FETCH_SUCCEEDED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: payload}); }else{ yield put({type: "TEST_FETCH_FAILED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: {message:error}}); } }
このtestjson.jsみたいに、個別のActionにはserverのapiごとに個別に作成する。
そしてその中でどのstatusを書き換えるかは個別に定義していく。
ただまあこうすると、apiからの結果を追加するcomponentを作るためには、
- componentのreducerを作って受け口となるstatusの名前を確定させる
- testjson.jsのようなaction用のjsの中で1)で作成したstatusを変更するput関数を入れ込む
というfileを渡り歩くような実装が必要になってこれがとても嫌だ。
ここだけどうにかならないかなあ。
そもそもどういう問題だっけ、これ
問題は2つあって、
- どのActionを実行すれば何が実行されるのか俯瞰できない
- どのStateを書き換えればいいのか俯瞰できない
container間もそうだし、containerとredux-sagaもそう。このActionとStateの相関が一目でわかるようになれば問題は解決する。するのだけれどさてどうしたもんか。
混乱なう
Actionから考えてみよう。
Actionは今別々のfileに以下のように定義されている。
export default function* Saga() { yield takeEvery("TEST_JSON_REQUEST", TestJSON); }
export default function* TestJSON(action) { const [payload,error] = yield call(getJSON, action.url); if(payload && error === undefined){ yield put({type: "apitest_TEST_FETCH_SUCCEEDED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: payload}); }else{ yield put({type: "apitest_TEST_FETCH_FAILED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: {message:error}}); } }
function mapDispatchToProps(dispatch) { return { getTestJSON(url) { dispatch({type: `${namespace}_GET`, url: url}); dispatch({type: 'TEST_JSON_REQUEST', url: url}) }, clearTestJSON() { dispatch({type: `${namespace}_CLEAR`}); }, }; }
……うん。後にしよう。Stateから行ってみようか。
えーっと待て。混乱してきたぞ。
reducerの中でactionと更新するstate, valueの組み合わせを定義している。 で、reducerはそれぞれ、
export function Reducer(state="", action) { switch (action.type) { case namespace+"_GET": return Object.assign({}, state, { url: action.url, value: undefined, }); case namespace+"_API_RESULT_SET": return Object.assign({}, state, { value: action.value, }); case namespace+"_CLEAR": return Object.assign({}, state, { url: undefined, value: undefined, }); default: return state; } }
export default function* TestJSON(action) { const [payload,error] = yield call(getJSON, action.url); if(payload && error === undefined){ yield put({type: "apitest_TEST_FETCH_SUCCEEDED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: payload}); }else{ yield put({type: "apitest_TEST_FETCH_FAILED", payload: payload, error: error}); yield put({type: "getform_API_RESULT_SET", value: {message:error}}); } }
のように定義されている。
これらのreducerを定義するときにどのactionの時にどのstateをどのようなvalueで更新すればいいのか、見通しが悪い、というのが今の問題。
気分転換
www.slideshare.net
あー。Forkが非同期呼び出しでCallが同期的な呼び出しだったのか。
で、やっぱりSagaにActionをまとめてしまおうという方向性はあるみたい。
あとReact FiberというReactのcore部分を改修しようというprojectもあって、これがうまくいくと描画も非同期にできるらしい。 まあこれはしばらくは関係なさそう。
閑話休題
混乱するのはcontainerの粒度におけるactionとreducerと、redux-sagaにあるactionとreducerの整理というか把握。
redux-sagaの場合、Saga()に定義されたActionを実行し、Actionの中でapiをfetchしてさらにActionを実行し、そこで初めてStoreにdataを保存したり、Containerの持つdataを反映するactionを実行したりしている。
もうこの時点でちょっとややこしい。できればStoreにdataを保存するだけにしたい。Containerへの反映はContainerの中だけで書き終えたい。
mapStateToPropsをいじればいけるかな?
Containerではどうしているかと言えば、mapDispatchToPropsでActionを定義して、Containerへとつなぎ込んでいる。で、Containerの中でActionが実行されると、これらが呼び出され、Reducer()が実行される。Reducerは受け取ったActionのtypeに沿ってstateのvalueを更新する。
まとめていくとActionを起点にして、
- Action => method()
- Action => Store
への2つのpipelineが存在する。これをSagaとContainerでどう定義しているかが違うから混乱しているだけ。
で、ここに俯瞰しやすさとか、他のContainerから別のContainerを操作したいとか、Actionが入り交じることになった結果混乱している、と。
まあContainerのReducerはあとでReduxによって結合されるわけだけど。
あー。なんか見えた。 Containerの中で定義されているmapDispatchToPropsはあくまでFormの動きを制御したりという、Containerの内部のふるまいと、外部APIを叩くためのrequestを記述したものが混ざってる。
それはSaga()の中で定義されているActionも同じ。
まあ結局Actionの俯瞰、整理、という問題になるわけか……。
こうしてみることにした
Containerでは Reducer(state, action) = New Stateとなる。
結局Redux-Sagaではapi callしかしないだろうから、その結果を特定のnamespaceにstateとして保持させて、これをContainer側で参照するようにすればいいのでは? という気もする。
そうすれば少なくともRedux-SagaとContainerの関係は解決する。Container間の関係はどうしたもんかという感じだけど。
でもmapStateToPropsにtest: state.TEST_JSON_FETCH_SUCCEEDEDとか定義しても登録されないな。
redux-sagaのputで呼び出されるのはactionだけか。
redux-sagaで呼び出すActionのparameterにhookという要素を用意する。action.hookがあればそれを実行する、という方式にしてみた。
すると、Containerの方では
//////////////////////////////////////////////////////////////////////// // Action function mapDispatchToProps(dispatch) { return { getTestJSON(url) { dispatch({type: `${namespace}_GET`, url: url}); dispatch({type: 'TEST_JSON_REQUEST', url: url, hook: `${namespace}_API_RESULT_SET`}) }, clearTestJSON() { dispatch({type: `${namespace}_CLEAR`}); }, }; }
こんな感じにredux-sagaに定義したdispatchを発行すればよくなり、redux-sagaの方では、
export default function* TestJSON(action) { const [payload,error] = yield call(getJSON, action.url); const result = {}; if(payload && error === undefined){ yield put({type: "TEST_JSON_FETCH_SUCCEEDED", payload: payload, error: error}); result.value = payload; }else{ yield put({type: "TEST_JSON_FETCH_FAILED", payload: payload, error: error}); result.value = {message:error}; } if("hook" in action){ result.type = action.hook; yield put(result); } }
こんな感じにhookの処理を最初からsupportしておけばとりあえずContainer側で受け取ることが出来るし、redux-sagaのAction定義では特にContainerの差異は気にしないで済む。
そもそも下手にredux-sagaに全部定義してどんなactionも連携できるようにするとやりすぎてloopしそうで怖かったので、できれば定義は分けたかったという。
これならいいかんじかな。
まあ実は全部のActionをredux-sagaの方に入れてしまおうという提案もあるのだけれど。こういう。
今はこう、
こんな感じにActionがSagaを経由する場合と直接Reducerを恵与する場合の2系統のpipelineがあるので、ややこしいと言えばややこしい。
でもContainerを一つ外すならContainerだけを外せばいいように実装したい僕としては、Sagaに関わる部分にContainerのActionも入れ込むのはちょっと嫌だった(Containerのimportを削除するだけじゃなくて、Sagaの方もいじる必要があるから)。
これを基本として調整していくかな。
file整理
redux-sagaのactionでやっている、TEST_JSON_FETCH_SUCCEEDEDやTEST_JSON_FETCH_FAILED"は、debugの時だけで本番では動かないようにしておこっと。
ためしにざっとAPI Referenceを見てみたけど、redux-sagaで直接Storeをいじるapiはなさそう。
そうすれば同じpage内で異なるcontainerが同じapiを叩いてもdataが共有されるからいいかなと思ったんだけど、いや冷静に考えてそんなpage組むなという話なのではやくもこの話は終了ですね。
で、saga関係は/src/apiserver
以下に配置。あとはserverの名前ごとにdirを作ればたとえ複数serverでも……そこまでする必要あるか? 複数のserverを叩く事なんてあるか?
ないだろ。それくらいだったらserver側でproxyするわ。
/src/saga
を作ってそこに配置。
./src ├── components │ ├── GetForm │ │ ├── FormApp.jsx │ │ ├── ResultDisplay.jsx │ │ └── index.jsx │ ├── HelloWorld │ │ └── index.jsx │ ├── ReflectForm │ │ ├── FormApp.jsx │ │ ├── FormDisplay.jsx │ │ ├── FormInput.jsx │ │ └── index.jsx │ └── RouteTest │ └── index.jsx ├── pages │ ├── base.jsx │ └── routing.jsx ├── root │ └── test.jsx └── saga ├── index.js ├── testjson.js └── utils.js
Container整理
あとはContainerのここ。
//////////////////////////////////////////////////////////////////////// // State export const namespace = "GetForm"; export let State = { value: {}, url: "", }; function mapStateToProps(state) { return { value: state[namespace].value, url: state[namespace].url, }; } //////////////////////////////////////////////////////////////////////// // Action export function Reducer(state="", action) { switch (action.type) { case `${namespace}_GET`: return Object.assign({}, state, { url: action.url, value: undefined }); case `${namespace}_API_RESULT_SET`: return Object.assign({}, state, { value: action.value }); case `${namespace}_CLEAR`: return Object.assign({}, state, { url: undefined, value: undefined }); default: return state; } } function mapDispatchToProps(dispatch) { return { getTestJSON(url) { dispatch({type: `${namespace}_GET`, url: url}); dispatch({type: 'TEST_JSON_REQUEST', url: url, hook: `${namespace}_API_RESULT_SET`}) }, clearTestJSON() { dispatch({type: `${namespace}_CLEAR`}); }, }; }
store関連の定義だけどもうちょっとどうにかならないかなあと。
こんな風にしてみた。
実際にはStateのkeyをその都度取ってくるのは無駄なのでconstで定義した物を使うつもり。
export const namespace = "GET_FORM"; export let State = { value: {}, url: "", }; function mapStateToProps(state) { let result = {}; Object.keys(State).map( (key) => result[key] = state[namespace][key] ); return result; }
動くことを確認できたのでさらに調整するとこんな感じかな。
//////////////////////////////////////////////////////////////////////// // State export let State = { value: {}, url: "", }; const StateKeys= Object.keys(State);
//////////////////////////////////////////////////////////////////////// // Container export const Container = connect( (state) => { let result = {}; StateKeys.map( (key) => result[key] = state[namespace][key] ); return result; }, mapDispatchToProps )(FormApp);
ここまで来るとContainerもclassにしてしまった方がいい気がしてきた。形式変わらないし。
で、newで作ったClassをexportさせる方式にした方が、importでいくつも書く必要が無い。こちらで作ったContainer関連のmethodをまとめるための関数に与える引数も減る。
その分Classでwrapする分見通しは悪くなるけど。どうしようかな。
やってみたけどあんまり抽象化になってないのでやめておく。
ひとまず今の/src/components/GetForm/index.jsx
はこんな感じ。
import { connect } from 'react-redux'; import FormApp from './FormApp'; export const namespace = "GET_FORM"; //////////////////////////////////////////////////////////////////////// // State export let State = { value: {}, url: "", }; //////////////////////////////////////////////////////////////////////// // Action export function Reducer(state="", action) { switch (action.type) { case `${namespace}_GET`: return Object.assign({}, state, { url: action.url, value: undefined }); case `${namespace}_API_RESULT_SET`: return Object.assign({}, state, { value: action.value }); case `${namespace}_CLEAR`: return Object.assign({}, state, { url: undefined, value: undefined }); default: return state; } } function mapDispatchToProps(dispatch) { return { getTestJSON(url) { dispatch({type: `${namespace}_GET`, url: url}); dispatch({type: 'TEST_JSON_REQUEST', url: url, hook: `${namespace}_API_RESULT_SET`}) }, clearTestJSON() { dispatch({type: `${namespace}_CLEAR`}); }, }; } //////////////////////////////////////////////////////////////////////// // Container const StateKeys= Object.keys(State); export const Container = connect( (state) => { let result = {}; StateKeys.map( (key) => result[key] = state[namespace][key] ); return result; }, mapDispatchToProps )(FormApp);
おまけ: log周り
Containerはこれでいいか。
Componentsも特にいじるところはない。
あとはredux-sagaでjson叩いたときのerror message取得部分が何故かちゃんと動いてないので調べる。
console.log(error)
で見ても普通にtextだったけどどうしてもStoreに登録されてないので、ためしにtypeof
で見てみたらObjectだった。
そうか、throw new Error(message)
で呼び出したやつだからか……。
error.message
を返すようにして対処
でもそもそもthrowでErrorが上がるなら検知できるのでもうpayloadだけでいいか?
それはそれでdebug logの生成が面倒になったのでこのままで行く。
ということでこれで一区切り?
うん。一区切りだ。今後はredux-sagaのcallじゃなくてforkを使ってみたいとかいろいろあるけどここまで長いthemeにはならないだろうし。
react+redux+react-router-redux+redux-sagaの環境構築、おーわり。あー長かった。
GitHub
ここまで+次の記事の内容までをv0.1.2として公開。