Help us understand the problem. What is going on with this article?

SPAをRails + React + Redux + Routerで実現&解説

More than 3 years have passed since last update.

SPAをRails + React + Redux + Routerで実現して、解説してる記事があまりなかったのでまとめます。
ReduxのTodoアプリをベースにAPIリクエストするサンプルを作りました。

実現してること

  • React + Redux + RouterでSPA
  • APIリクエストでCRU(D)処理まで

対象

それぞれの技術の概念は理解してるが、実際にコードを書いたことがない方

実装方針

  • 基本的にライブラリのサンプルの実装方針を踏襲(差分は後述)
  • ServerSide Renderingも対応できるようにする
  • Productionで使えて、ある程度の規模まで対応した構成
  • javascriptはes2015で書く
  • APIリクエストはthunkを使う
  • BuildはWebpackで行う

github

https://github.com/akichim21/rails-react-spa

Webpack

babelでビルドする。eslintはpreloadersでやるとconsole.logとか仕込めないので、commit hookとかciとかのが良さそう。

config/webpack.config.js
const DEBUG = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined;

module.exports = {
  cache: DEBUG,
  debug: DEBUG,
  devtool: DEBUG ? 'inline-source-map' : false,
  entry: {
  },
  output: {
    path: '../app/assets/javascripts/components',
    filename: '[name].js'
  },
  module: {
    loaders: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loader: 'babel?presets[]=react,presets[]=es2015,presets[]=stage-2'
      },
      { test: /\.json$/, loader: 'json' }
    ],
    preLoaders: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        loader: "eslint-loader"
      }
    ]
  },
  eslint: {
    configFile: './.eslintrc.json'
  },
  resolve: {
    extensions: ['', '.js', '.jsx', 'json'],
  },
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM'
  }
}

リクエストの処理フロー

一覧
url: /todos
スクリーンショット 2016-08-31 14.27.31.png

view

react-railsというgemを使って、変数などをReactのアプリケーションに渡して、レンダリングを行ってもらう。
例としてtokenを渡す。

app/views/todo/index.html.erb
<%= react_component("Todo", { token: form_authenticity_token}, {}) %>

React

SPAのendpoint。 webpackでbuildするところでもある。
今回、変数はlocalstorageで保存して、apiリクエスト時に使う。initialStateとしてdispatchしても使える。
globalなwindowに渡さないとTodoクラスがreact-railsにはわからないので、windowに渡す。
ざっくり言うとProvider, storeが変数を管理するredux側のモジュール(action,reducer,container,componentで変数を伝搬してくれる)で、
history, Routerがreact-routerでSPAのルーティングを実現するモジュール群です。
Route pathでurlマッチさせて、componentで指定されてるものが呼ばれます。

frontend/javascripts/components/Todo.js
const storeArgs = combineReducers({
  ...reducers,
  routing: routerReducer
})

const store = process.env.NODE_ENV === 'production' ? prodConfigureStore(storeArgs) : devConfigureStore(storeArgs)

const history = syncHistoryWithStore(browserHistory, store)

const propTypes = {
  token: React.PropTypes.string.isRequired,
}

export default class Todo extends React.Component {
  componentWillMount() {
    localStorage.setItem("token", this.props.token)
  }
  render() {
    return (
      <Provider store={store}>
        <Router history={history}>
          <Route path="todos" component={Index}/>
          <Route path="todos/:id" component={Show}/>
        </Router>
      </Provider>
    )
  }
}
Todo.propTypes = propTypes

window.Todo = Todo

urlが/todosなので、Indexのcomponentが呼ばれる

frontend/javascripts/components/todos/Index.js
export default class Index extends React.Component {
  render() {
    return (
      <div>
        <CreateTodo />
        <TodoList />
      </div>
    )
  }
}

あとはそれぞれCreateTodo, TodoListが展開される。
todosの情報のapi通信はTodoListのcomponentWillMountの段階で呼ばれる。

Action

(未)完了にするボタンをクリック

onClickが呼ばれる。これは親コンポーネントのTodoList.jsから渡される。onClickの中ではupdateTodoのactionメソッドが呼ばれる。

frontend/javascripts/components/todos/Todo.js
export default class Todo extends React.Component {
  render() {
    const { id, isCompleted, text, onClick } = this.props
    return (
      <li>
        <p style={{display: "inline", textDecoration: isCompleted ? 'line-through' : 'none'}}>{text}</p>
        [<Link to={"todos/" + id }>詳細</Link>]
        [<a href="#" onClick={onClick}>{isCompleted ? "未完了" : "完了"}にする</a>]
      </li>
    )
  }
}

updateTodoはTodoListのcontainerでdispatchを注入して使えるようにしている。mapStateToPropsとmapDispatchToPropsはstateとdispatchをcontainerで使えるようにするもの。

frontend/javascripts/containers/todos/TodoList.js
const mapStateToProps = (state) => {
  return {
    todos: state.todos.todos
  }
}

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

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

api通信でUPDATE_TODO_**でそれぞれ後続のreducer処理できる。REQUESTはリクエストした瞬間、SUCCESSは成功時、FAILUREはエラー時に呼ばれる。CALL_APIをMOCK_APIにすれば、api通信せずに開発を進めることも可能。

actions/todos/index.js
export const updateTodo = (id, param) => {
  return {
    [CALL_API]: {
      types: [ types.UPDATE_TODO_REQUEST, types.UPDATE_TODO_SUCCESS, types.UPDATE_TODO_FAILURE ],
      endpoint: "todos/" + id + ".json",
      param: { id: id,  todo: param },
      method: "PUT"
    }
  }
}

ここで処理をかまして、fetchのリクエストからpromiseで処理を繋いで、reducerへ。
middleware/api.js

今回UPDATE_TODO_SUCCESSの時だけtodosの更新したオブジェクトを更新後のオブジェクトに変更。REQUEST時にloadingのフラグ、FAILURE時にエラーメッセージの表示など本番のプロダクトでは入れることができる。

reducers/todos/todos.js
const INITIAL_STATE = {
  todos: [],
  todo: undefined,
}

const todos = (state = INITIAL_STATE, action) => {
  switch (action.type) {
    case types.UPDATE_TODO_SUCCESS:
      return Object.assign({}, state, {
        todos: state.todos.map(t => {
          if (t.id == action.response.todo.id) {
            return action.response.todo
          } else {
            return t
          }
        })
      })
    default:
      return state
  }
}

state.todos.todosが変更されて、containerが受け取りcomponentに渡す。
componentが変更を受け取ったらreactが差分変更してくれる。

frontend/javascripts/components/todos/TodoList.js
const mapStateToProps = (state) => {
  return {
    todos: state.todos.todos
  }
}
frontend/javascripts/containers/todos/TodoList.js
export default class TodoList extends React.Component {
  componentWillMount() {
    this.props.actions.fetchTodos()
  }
  render() {
    const { todos } = this.props
    const { updateTodo } = this.props.actions
    return (
      <ul>
        {todos.map(todo =>
          <Todo
            key={todo.id}
            {...todo}
            onClick={() => updateTodo(todo.id, { is_completed: !todo.isCompleted })}
          />
        )}
      </ul>
    )
  }
}

ページ遷移

[詳細]をクリック

url: todos/1
スクリーンショット 2016-08-31 13.58.39.png

詳細の形で指定してあげるとreact-routerがSPA遷移してくれる。action形式ならbrowserHistory.push({ pathname: "todos/" + id, query: { hoge: hoge } })で遷移可能。

frontend/javascripts/containers/todos/Todo.js
export default class Todo extends React.Component {
  render() {
    const { id, isCompleted, text, onClick } = this.props
    return (
      <li>
        <p style={{display: "inline", textDecoration: isCompleted ? 'line-through' : 'none'}}>{text}</p>
        [<Link to={"todos/" + id }>詳細</Link>]
        [<a href="#" onClick={onClick}>{isCompleted ? "未完了" : "完了"}にする</a>]
      </li>
    )
  }
}

RouteのパスにマッチしたShowのcomponentを呼び出す。

frontend/javascripts/components/Todo.js
export default class Todo extends React.Component {
  componentWillMount() {
    localStorage.setItem("token", this.props.token)
  }
  render() {
    return (
      <Provider store={store}>
        <Router history={history}>
          <Route path="todos" component={Index}/>
          <Route path="todos/:id" component={Show}/>
        </Router>
      </Provider>
    )
  }
}

サンプルとの差分:
- actionのメソッドをrailsのapiよりに変える(updateTodo, createTodoなど)
- componentのクラスをconst *で定義してるのをexport default class * extends React.Componentで統一
- containerでbindActionCreatorsしてdispatchをここで注入して、actionsに統一で入れる
- railsのapiで整形して渡しやすいので、thunkのschema廃止。

ポリシーが迷う所:
- container作るかcomponentだけか。actionがあるものやstateから値をとるものはcontainerを作ってる。

参考

https://github.com/reactjs/react-redux/blob/master/docs/api.md#connectmapstatetoprops-mapdispatchtoprops-mergeprops-options
http://redux.js.org/docs/basics/ExampleTodoList.html
https://github.com/reactjs/react-router/tree/master/docs

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away