React Tutorial Example (Convention)


[React](https://facebook.github.io/react/) や [Redux](http://redux.js.org/) には推奨されている規約がいくつかあります。
[T.J. の Frontend Boilerplate](https://github.com/tj/frontend-boilerplate) や [Airbnb React Style Guide](https://github.com/airbnb/javascript/tree/master/react) を参考にして
[前回](http://qiita.com/ogomr/items/3f0d7a35d5d0184db019) のアプリケーションに規約を適用してみましょう。

## Learning

* ファイル、ディレクトリの *命名規約* を適用する。
* アクションに *Flux Standard Action* を利用する。

## Convention

React の Data Flow は *Container -> Component* の単一方向です。
Redux の Data Flow は *Action -> Reducer -> Store* の単一方向です。
つまり Data Flow の Context でディレクトリ, ファイルに分離します。

* ディレクトリには `index.js` が設置され `import` ではディレクトリの名前を指定する。
* *コンテナ* や *コンポーネント* はキャメルでディレクトリの名前を指定する。
* *コンテナ* や *コンポーネント* は1つの *ファイル* で1つの *クラス* を定義する。
* アクションは [Flux Standard Action](https://github.com/acdlite/flux-standard-action) で定義する。
* ディレクトリの名前で設置する種類を定義する。
    * `containers`: コンポーネント（部品）を載せる コンテナ を設置する。
    * `components`: アプリケーションのコンポーネント（部品）を設置する。
    * `actions`: アプリケーションのアクション（行動）を設置する。
    * `reducers`: アクション（行動）による状態を変更する機能を設置する。
    * `store`: アプリケーションの状態を保持する機構を設置する。

## Environment

* node: v4.4.5
* npm: v3.9.6

## Comment Box Form

* 完成される [Source Code](https://github.com/ogom/react-comment-box-example/tree/convention) のファイルリストです。

```bash
$ tree -a -I node_modules
.
├── .babelrc
├── app
│   ├── index.js
│   ├── actions
│   │   └── index.js
│   ├── components
│   │   ├── Comment
│   │   │   └── index.js
│   │   ├── CommentBox
│   │   │   └── index.js
│   │   ├── CommentForm
│   │   │   └── index.js
│   │   └── CommentList
│   │       └── index.js
│   ├── containers
│   │   └── App
│   │       └── index.js
│   ├── reducers
│   │   └── index.js
│   └── store
│        └── index.js
├── index.html
├── package.json
├── style.css
└── webpack.config.js
```

*Let's hands-on*

### Setup application

* `git clone` コマンドでアプリケーションをダウンロードします。
* `npm install` コマンドで依存するモジュールをインストールします。

```bash
$ git clone https://github.com/ogom/react-comment-box-example.git
$ cd react-comment-box-example
$ git checkout es2015
$ npm install
```

### Start HTTP Server

* `npm start` コマンドで *Webアプリケーション* を実行します。
* ブラウザで http://localhost:4000 をロードすると **Comment Box Example** が表示されます。

```bash
$ npm start
$ open http://localhost:4000
```

(API Server は [React Tutorial Example (Express)](http://qiita.com/ogomr/items/b90c68764c3c75cf501e) をご利用ください。)

### Webpack Entry

* まずは `app` ディレクトリを作成します。
* そこに `index.js` ファイルを移動します。

```bash
$ mkdir app/
$ mv index.js app/.
```

* `app/index.js` に移動したので `entry` を `app/index` に変更します。

```diff:webpack.config.js
 var webpack = require('webpack')

 module.exports = {
-  entry: './index',
+  entry: './app/index',
   output: {
     filename: 'bundle.js'
   },
```

* `app.js` は一つ上の階層なので `../app` に変更します。

```diff:app/index.js
-import CommentBox from './app'
+import CommentBox from '../app'
```

### Containers

* つぎにコンテナの `app/containers` ディレクトリを作成します。
* そこに `app.js` ファイルを移動します。

```bash
$ mkdir app/containers
$ mkdir app/containers/App
$ mv app.js app/containers/App/index.js
```

* `../app` を `./containers/App` に変更します。

```diff:app/index.js
-import CommentBox from '../app'
+import CommentBox from './containers/App'
```

### Components

* そしてコンポーネントの `app/components` ディレクトリを作成します。

```bash
$ mkdir app/components
```

#### Comment

* `app/components/Comment/index.js` ファイルを作成します。

```bash
$ mkdir app/components/Comment
$ touch app/components/Comment/index.js
```

* コンテナの `class Comment` を `app/components/Comment/index.js` に移動します。
    * *コードが差分で表示されていない場合は、全て差し替えてください。*

```js:app/components/Comment/index.js
import React, { Component } from 'react'
import Remarkable from 'remarkable'

class Comment extends Component {
  rawMarkup() {
    const md = new Remarkable()
    const rawMarkup = md.render(this.props.children.toString())
    return { __html: rawMarkup }
  }

  render() {
    return (
      <div className="comment">
        <h2 className="commentAuthor">
          {this.props.author}
        </h2>
        <span dangerouslySetInnerHTML={this.rawMarkup()} />
      </div>
    )
  }
}

export default Comment
```

* コンテナの `Remarkable` はコンポーネントに移動したので削除します。
* コンテナに `Comment` コンポーネントをインポートします。

```diff:app/containers/App/index.js
-import Remarkable from 'remarkable'
+import Comment from '../../components/Comment'
```

#### CommentList

* `app/components/CommentList/index.js` ファイルを作成します。

```bash
$ mkdir app/components/CommentList
$ touch app/components/CommentList/index.js
```

* コンテナの `class CommentList` を `app/components/CommentList/index.js` に移動します。

```app/components/CommentList/index.js
import React, { Component } from 'react'
import Comment from '../../components/Comment'

class CommentList extends Component {
  render() {
    const commentNodes = this.props.data.map((comment) => {
      return (
        <Comment author={comment.author} key={comment.id}>
          {comment.text}
        </Comment>
      )
    })
    return (
      <div className="commentList">
        {commentNodes}
      </div>
    )
  }
}

export default CommentList
```

* コンテナの `Comment` はコンポーネントに移動したので削除します。
* コンテナに `CommentList` コンポーネントをインポートします。

```diff:app/containers/App/index.js
-import Comment from '../../components/Comment'
+import CommentList from '../../components/CommentList'
```

#### CommentForm

* `app/components/CommentForm/index.js` ファイルを作成します。

```bash
$ mkdir app/components/CommentForm
$ touch app/components/CommentForm/index.js
```

* コンテナの `class CommentForm` を `app/components/CommentForm/index.js` に移動します。

```app/components/CommentForm/index.js
import React, { Component } from 'react'

class CommentForm extends Component {
  constructor(props) {
    super(props)
    this.state = {author: '', text: ''}
  }

  handleAuthorChange(e) {
    this.setState({author: e.target.value})
  }

  handleTextChange(e) {
    this.setState({text: e.target.value})
  }

  handleSubmit(e) {
    e.preventDefault()
    const author = this.state.author.trim()
    const text = this.state.text.trim()
    if (!text || !author) {
      return
    }
    this.props.onCommentSubmit({author: author, text: text})
    this.setState({author: '', text: ''})
  }

  render() {
    return (
      <form className="commentForm" onSubmit={this.handleSubmit.bind(this)}>
        <input
          type="text"
          placeholder="Your name"
          value={this.state.author}
          onChange={this.handleAuthorChange.bind(this)}
        />
        <input
          type="text"
          placeholder="Say something..."
          value={this.state.text}
          onChange={this.handleTextChange.bind(this)}
        />
        <input type="submit" value="Post" />
      </form>
    )
  }
}

export default CommentForm
```

* コンテナに `CommentForm` コンポーネントをインポートします。

```diff:app/containers/App/index.js
+import CommentForm from '../../components/CommentForm'
```

#### CommentBox

* `app/components/CommentBox/index.js` ファイルを作成します。

```bash
$ mkdir app/components/CommentBox
$ touch app/components/CommentBox/index.js
```

* コンテナの `class CommentBox` を `app/components/CommentBox/index.js` に移動します。
* `this.props.showComments` を `this.props.actions.showComments` に変更します。
* `this.props.addComment` を `this.props.actions.addComment` に変更します。

(*Middleware* は利用しないでコンテナに *Ajax* を残しています。)

```app/components/CommentBox/index.js
import React, { Component } from 'react'
import CommentList from '../../components/CommentList'
import CommentForm from '../../components/CommentForm'
import $ from 'jquery'

class CommentBox extends Component {
  constructor(props) {
    super(props)
    this.state = {data: []}
  }

  loadCommentsFromServer() {
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      cache: false,
      success: function(data) {
        this.props.actions.showComments(data)
      }.bind(this),
      error: function(xhr, status, err) {
        console.error(this.props.url, status, err.toString())
      }.bind(this)
    })
  }

  handleCommentSubmit(comment) {
    const comments = this.state.data
    // Optimistically set an id on the new comment. It will be replaced by an
    // id generated by the server. In a production application you would likely
    // not use Date.now() for this and would have a more robust system in place.
    comment.id = Date.now()
    this.props.actions.addComment(comment)
    $.ajax({
      url: this.props.url,
      dataType: 'json',
      type: 'POST',
      data: comment,
      success: function(data) {
        this.props.actions.showComments(data)
      }.bind(this),
      error: function(xhr, status, err) {
        this.props.actions.showComments(comments)
        console.error(this.props.url, status, err.toString())
      }.bind(this)
    })
  }

  componentDidMount() {
    this.loadCommentsFromServer()
    setInterval(this.loadCommentsFromServer.bind(this), this.props.pollInterval)
  }

  render() {
    return (
      <div className="commentBox">
        <h1>Comments</h1>
        <CommentList data={this.props.data} />
        <CommentForm onCommentSubmit={this.handleCommentSubmit.bind(this)} />
      </div>
    )
  }
}

export default CommentBox
```

* コンテナに `CommentBox` コンポーネントをインポートします。
* `CommentBox` のプロパティに `data` と `actions` を設定します。

```app/containers/index.js
import React, { Component } from 'react'
import CommentBox from '../../components/CommentBox'
import { connect } from 'react-redux'

class App extends Component {
  render() {
    const { data, actions } = this.props
    return (
      <div>
        <CommentBox
          data={data}
          actions={actions}
          url="http://localhost:3000/api/comments"
          pollInterval={2000}
        />
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    data: state
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    actions: {
      showComments(comments) {
        dispatch({type: 'show_comments', comments: comments})
      },
      addComment(comment) {
        dispatch({type: 'add_comment', comment: comment})
      }
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(App)
```

* `CommentBox` を `App` に変更します。
* `App` のプロパティを `<App />` に変更します。

```app/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'

import App from './containers/App'

const store = createStore((state=[], action) => {
  switch (action.type) {
  case 'show_comments':
    return action.comments
  case 'add_comment':
    return state.concat([action.comment])
  default:
    return state
  }
})

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('content')
)
```

### Store

* `app/store/index.js` ファイルを作成します。

```bash
$ mkdir app/store
$ touch app/store/index.js
```

* `createStore` を `app/store/index.js` に移動します。

```js:app/store/index.js
import { createStore } from 'redux'

export default () => {
  const store = createStore((state=[], action) => {
    switch (action.type) {
    case 'show_comments':
      return action.comments
    case 'add_comment':
      return state.concat([action.comment])
    default:
      return state
    }
  })

  return store
}
```

* `configureStore` をインポートします。

```js:app/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'

import App from './containers/App'
import configureStore from './store'

const store = configureStore()

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('content')
)
```

### Reducers

* `app/reducers/index.js` ファイルを作成します。

```bash
$ mkdir app/reducers
$ touch app/reducers/index.js
```

* リデューサを `app/reducers/index.js` に移動します。

```js:app/reducers/index.js
export default (state=[], action) => {
  switch (action.type) {
  case 'show_comments':
    return action.comments
  case 'add_comment':
    return state.concat([action.comment])
  default:
    return state
  }
}
```

* ストアに `reducers` をインポートします。

```js:app/store/index.js
import { createStore } from 'redux'
import reducers from '../reducers'

export default () => {
  return createStore(reducers)
}
```

### Actions

* Flux Standard Action のモジュールをインストールします。

```bash
$ npm install redux-actions --save-dev
```

* `app/reducers/index.js` ファイルを作成します。

```bash
$ mkdir app/actions
$ touch app/actions/index.js
```

* アクションに `createAction` で `showComments` を作成します。
* アクションに `createAction` で `addComment` を作成します。

```app/actions/index.js
import { createAction } from 'redux-actions'

export const showComments = createAction('show_comments')
export const addComment = createAction('add_comment')
```

* リデューサに `handleActions` で `reducerMap` を設定します。

```app/reducers/index.js
import { handleActions } from 'redux-actions'

const initialState = []

const reducerMap = {
  show_comments(state, action) {
    return action.payload
  },
  add_comment(state, action) {
    return state.concat([action.payload])
  }
}

export default handleActions(reducerMap, initialState)
```

* コンポーネントに `bindActionCreators` で `Actions` を設定します。

```app/containers/App/index.js
import React, { Component } from 'react'
import CommentBox from '../../components/CommentBox'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as Actions from '../../actions'

class App extends Component {
  render() {
    const { data, actions } = this.props
    return (
      <div>
        <CommentBox
          data={data}
          actions={actions}
          url="http://localhost:3000/api/comments"
          pollInterval={2000}
        />
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    data: state
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    actions: bindActionCreators(Actions, dispatch)
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(App)
```

*Congrats!*

----

*コード* を **Component**, **Container**, **Action**, **Reducer**, **Store** の *ファイル* に分離できました。
次は [CSS を ローカルスコープ](http://qiita.com/ogomr/items/1f8f18a18ac9c2e79179) にしましょう！