ReactJS+Reduxで非同期Actionとコンポーネントの再利用

#ReactJS+Reduxで非同期Actionの実装をし、コンポーネントの再利用をする
[ECMAScript2015(es6)の基本的な開発環境](http://qiita.com/hiroykam/items/135c51310087182b03cb)で`Webpack`と`browserify`を利用したes6のビルド環境を作成した。そこで、CSVファイルを読み込んで、その内容（１列目で正規表現にヒットする）を反映するサンプルを取り扱ったが、そのサンプルを`React/Redux`で実現してみたかった。
また、そのサンプルでは非同期Actionとコンポーネントの再利用が必要で、実装するのに時間を要したので、環境構築方法も踏まえて、備忘録として記載。

##Webpackを利用したビルド環境
`Webpack`を利用した。`create-react-app`を`npm`コマンドでインストールして、`ReactJS`環境を作成するのもありだと思うが、同一のプロジェクトでes6だけで作り込んだページが存在したり、`Vue.js`を利用して作成することもありえるので、それら包括するビルド環境も必要になると思い、ここでは`Webpack`を利用した。もちろん`Webpack`以外でも実現はできると思うので、あくまで多少穿った個人的な意見。

###ディレクトリ構造
ここでは下図のディレクトリ構造とした。自分で言うのも、あれだが、あまり良くない。

<img width="262" alt="スクリーンショット 2017-07-07 0.28.28.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/b19f6f79-8570-0099-92a3-2a1c6220fe5d.png">

###インストール
`node.js`がインストールされて、パスが通っている環境を構築した後、`react/react_test/`ディレクトリ配下で、下記を実行する。
`redux-thunk`を導入しているのは、非同期のActionを利用しているため。

####npmの場合
```shell-session
$ npm init
$ npm install --save-dev webpack
$ npm install --save-dev react react-dom redux react-redux redux-thunk
$ npm install --save-dev babel-loader babel-core babel-preset-es2015 babel-preset-react
$ npm install --save-dev style-loader css-loader
```

####yarnの場合
```shell-session
$ yarn init
$ yarn add --dev webpack
$ yarn add --dev react react-dom redux react-redux redux-thunk
$ yarn add --dev babel-loader babel-core babel-preset-es2015 babel-preset-react
$ yarn add --dev style-loader css-loader
```

###package.json
インストールが完了すると、`package.json`が追加されているので、`scripts`に`production`のビルドと`development`のビルドを追加する。


```json:react/react_test/package.json
{
  "name": "react_test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "production": "webpack -p",
    "development": "webpack -d",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "react": "^15.6.1",
    "react-dom": "^15.6.1",
    "react-redux": "^5.0.5",
    "redux": "^3.7.1"
  },
  "devDependencies": {
    "babel-core": "^6.25.0",
    "babel-loader": "^7.1.1",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "css-loader": "^0.28.4",
    "redux-thunk": "^2.2.0",
    "style-loader": "^0.18.2",
    "webpack": "^3.0.0"
  }
}
```

###webpack.config.js
`webpack.config.js`を作成する。`React`で開発したソースの拡張子は`jsx`とすることを前提。

```js:react/react_test/webpack.config.js
var webpack = require('webpack');

module.exports = {
  entry: {
    '../js/app': './src/app.jsx'
  },
  output: {
    path: __dirname,
    filename: '[name].bundle.js'
  },
  module: {
    loaders: [
      {
        test: /\.css$/,
        loaders: ['style-loader', 'css-loader?modules'],
      },
      {
        test: /\.jsx$/,
        exclude: /(node_modules|.git)/,
        loader: 'babel-loader',
        query: {
          presets: ['es2015', 'react']
        }
      },
      { 
        test: /\.js$/, 
        exclude: /(node_modules|.git)/, 
        loader: "babel-loader", 
        query:{
          presets: ['es2015']
        }
      }
    ]
  }
};
```

##サンプル

###起動ページ
```html:react/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>React Test</title>
  </head>
  <body>
    <div class="react_container"></div>
    <script type="text/javascript" src="js/app.bundle.js"></script>
  </body>
</html>
```
###スタイルシート
下記cssをjsxで読み込む。

```css:react/react_test/css/style.css
.myMargin {
  margin: 20px;
}

.myHeight1 {
  height: 30px;
}

.myHeight2 {
  height: 50px;
}

.alertColor {
  color: red;
}
```

###Action（非同期も含む）とState
今回のサンプルでは同じコンポーネントを2つ利用（コンポーネントの再利用）しているため`name`でどのコンポーネントのActionなのか区別している。また、`onFileChange`は非同期のActionとして用意。処理が完了したタイミングで該当のActionを`dispatch`している。

```js:react/react_test/src/actions.jsx
export const CLEAR        = 'CLEAR'
export const READ         = "READ"
export const READ_SUCCESS = 'READ_SUCCESS'
export const READ_FAIL    = 'READ_FAIL'

export const onClickClear = (event, name) => ({
  type: CLEAR,
  name
})

const onStartRead = (name) => {
  return {
    type: READ,
    name
  }
}

const onFinishRead = (t, result, name) => {
  return {
    type: t,
    result,
    name
  }
}

export const onFileChange = (event, max, name) => {
  return dispatch => {
    const file     = event.target.files
    const reader   = new FileReader()
    reader.onload = (e) => {
      const csv   = e.target.result
      const lines = csv.split(/\r\n|\n/)
      let c = 0
      let t = ""
      lines.forEach((elm, idx) => {
        const data = elm.split(",")
        if (data.length > 0 && /^(https?)(:\/\/[-_.!~*'()a-zA-Z0-9;\/?:@&=+$,%#]+)$/.test(data[0])) {
          if (c >= max) {
            console.log('上限に達しています')
          } else {
            t += data[0] + "\n";
            c++
          }
        }
      })
      dispatch(onFinishRead(READ_SUCCESS, { text: t, lines: c }, name))
    }
    reader.onerror = () => {
      dispatch(onFinishRead(READ_FAIL, null, name))
    }
    if (file.length > 0) {
      dispatch(onStartRead(name))
      reader.readAsText(file[0])
    }
  }
}
```

###Reducer
AcitonとStateから新しいStateを作成する。
コンポーネントの再利用をしているため、2つのStateを管理するようにしている。

```js:react/react_test/src/reducer.jsx
import { combineReducers } from 'redux'
import * as actions from './actions.jsx'

const initialAppState = {
  message: "",
  text: "",
  lines: 0
}

const demo = (state = initialAppState, action) => {
  if (action.type === actions.CLEAR) {
    return {
      message: "クリアしました",
      text: "",
      lines: 0
    }
  } else if (action.type === actions.READ) {
    return {
      message: "Now Reading...",
      text: "",
      lines: 0
    }
  } else if (action.type === actions.READ_SUCCESS) {
    return {
      message: action.result.lines + "件登録しました",
      text: action.result.text,
      lines: action.result.lines
    }
  } else if (action.type === actions.READ_FAIL) {
    return {
      message: "ファイルの読込に失敗しました",
      text: "",
      lines: 0
    }
  } else {
    return state
  }
}

const createNamedWrapperReducer = (reducerFunction, reducerName) => {
    return (state, action) => {
        const {name} = action;
        const isInitializationCall = state === undefined;
        if(name !== reducerName && !isInitializationCall) return state;
        return reducerFunction(state, action)
    }
}

const reducer = combineReducers({
    demoA : createNamedWrapperReducer(demo, 'A'),
    demoB : createNamedWrapperReducer(demo, 'B'),
})

export default reducer
```

###Component
このサンプルでContainerを細かく分けるかどうか悩んだが、備忘録も兼ねたかったので、細分化した。

```js:react/react_test/src/components.jsx
import React from 'react'
import style from '../css/style.css';

export const alert = ({ message }) => (
  <div className={ style.alertColor }>{ message }</div>
)

export const currentStatus = ({ n }) => (
  <div>現在{ n }件登録されています<br /></div>
)

export const textArea = ({ max, text }) => {
  let s = style.myHeight1
  if (max > 3) s = style.myHeight2
  return (<div><textarea className={ s } value={ text } /><br /></div>)
}

export const clearButton = ({ onClick }) => (
  <button onClick={ onClick }>クリア</button>
)

export const inputFile = ({ onChange }) => (
  <input type="file" onChange={ onChange } />
)
```

###Container（Componentの再利用）
Componentだけど、複数のComponentを束ねているということでContainerとして位置づけた。
コンポーネントを再利用しているため、`demoA`と`demoB`の2つを用意した。

```js:react/react_test/src/DemoComponent.jsx
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as components from './components.jsx'
import * as actions from './actions.jsx'
import style from '../css/style.css'

class DemoContainer extends Component {
  render() {
    const { demoA, demoB, actions } = this.props;
    return (
      <div>
        <div className={ style.myMargin }>
          <components.alert message={ demoA.message } />
          <components.currentStatus n={ demoA.lines } />
          <components.textArea max={ 3 } text={ demoA.text } />
          <components.clearButton onClick={ (e) => actions.onClickClear(e, "A") } />
          <components.inputFile onChange={ (e) => actions.onFileChange(e, this.props.maxLengthA, "A") } />
        </div>
        <div className={ style.myMargin }>
          <components.alert message={ demoB.message } />
          <components.currentStatus n={ demoB.lines } />
          <components.textArea max={ 5 } text={ demoB.text } />
          <components.clearButton onClick={ (e) => actions.onClickClear(e, "B") } />
          <components.inputFile onChange={ (e) => actions.onFileChange(e, this.props.maxLengthB, "B") } />
        </div>
      </div>
    )
  }
}

const mapState = (state, ownProps) => ({
  demoA: state.demoA,
  demoB: state.demoB
});

function mapDispatch(dispatch) {
  return {
    actions: bindActionCreators(actions, dispatch)
  }
}

export default connect(mapState, mapDispatch)(DemoContainer)
```

最後に`app.jsx`でコンテナの呼び出しと、ストアを作成する。

```js:react/react_test/src/app.jsx
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import thunk from 'redux-thunk'
import { createStore, applyMiddleware } from 'redux'
import reducer from './reducer.jsx'
import DemoComponent from './DemoComponent.jsx'

const store = createStore(reducer, applyMiddleware(thunk))

render (
  <Provider store={ store }>
    <DemoComponent maxLengthA="3" maxLengthB="6" />
  </Provider>,
  document.querySelector('.react_container')
)
```

##サンプルの実行
###ビルド
パターン1、パターン2のいずれかのコマンドでビルドする。`development`はデバッグができる。

####パターン1
#####npmの場合

```shell-session
$ npm run development
```

#####yarnの場合

```shell-session
$ yarn run development
```

####パターン2
#####npmの場合

```shell-session
$ npm run production
```

#####yarnの場合
```shell-session
$ yarn run production
```

###csvファイル
今回利用したcsvファイルはいつもと同様に以下。正規表現で`http`の形式にマッチしない場合はスキップされているかどうも確認できる。

```text:test.csv
http://hogehoge/01
ftp://hogehoge/02
http://hogehoge/03
http:/hogehoge/04
http://hogehoge/05
http://ほげほげ/06
http://hogehoge/07
http://hogehoge/08
http://hogehoge/09
```

###デモ
`index.html`をブラウザで開いて、`test.csv`を読み込ませると下図のようになる。

<img width="251" alt="スクリーンショット 2017-07-07 0.28.01.png" src="https://qiita-image-store.s3.amazonaws.com/0/135559/bfd73e6b-e89f-7d42-8cd6-3709a774414c.png">