読者です 読者をやめる 読者になる 読者になる

かえるの井戸端雑記

開発日誌的な記事だったり備忘録だったり。まとめ記事と言うよりは、七転八倒の様子を小説みたいに読んで眺めてもらえればと。

react+redux+react-router-redux+redux-sagaの環境を自分なりに使いやすくしていく

今回の話

前回、redux-sagaを使えるようにしてみた。

frogwell.hatenablog.jp

使えたけど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を作るためには、

  1. componentのreducerを作って受け口となるstatusの名前を確定させる
  2. testjson.jsのようなaction用のjsの中で1)で作成したstatusを変更するput関数を入れ込む

というfileを渡り歩くような実装が必要になってこれがとても嫌だ。

ここだけどうにかならないかなあ。

そもそもどういう問題だっけ、これ

問題は2つあって、

  1. どのActionを実行すれば何が実行されるのか俯瞰できない
  2. どの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をまとめてしまおうという方向性はあるみたい。

html5experts.jp

あと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を起点にして、

  1. Action => method()
  2. 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の方に入れてしまおうという提案もあるのだけれど。こういう。

medium.com

今はこう、

https://image.slidesharecdn.com/20170323-reactfluxreduxreduxsaga-170324033405/95/react-flux-redux-redux-saga-118-638.jpg?cb=1490327643

こんな感じに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

ためしにざっと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として公開。

github.com