こんにちは、tanaka です。
Rails5.1で Webpacker が導入されて React や Vue.js などのフロントエンド技術がさらに身近になりました。ヽ( ´¬`)ノ
ということで、早速 Rails5.1 + React + Redux でちょっとしたアプリを作ってみるぜ !! とコーディングを始めたところ、
・・・・
ちょっと昔に ReactNative を勉強したときに使ったはずの Redux をガッツリ忘れているではありませんか …。 (ノ゚ο゚)ノ
そんな忘れん坊な私のためにReact + Reduxを復習し、今度こそ忘れないぞと誓うとともに記事としてまとめました。
React + Reduxの参考記事
React + Reduxについては詳しい記事がたくさんあります。
私も数回読みました。どれも分かりやすくて良い記事だと思います。
が、それでもまだチョット・・という自分のためにTodoアプリを例に出しつつ図を描きながら説明を試みます。
Reduxのスタンダードな解説はこれらの記事に譲ります。
React + Redux のキホン
覚えることは3つだけです。
- アプリケーションのすべての状態は ストア で一元管理する。
- ビューは state と action creator を受け取り、stateに従って描画するだけ
- 描画を変えたいときはビューが action を発動して state を更新する。(その結果、描画が更新される)
個人的に action は発射ボタンとしてイメージすると理解しやすくしっくりきました。
ビューは発射ボタンを受け取って<input />
などの要素のイベントに紐付けておき、マウスクリック等のユーザーイベントが発生したらそのスイッチが押されるイメージです。
Reducerは状態の管理人というイメージを持ちました。複数の管理人がそれぞれアプリケーションの部分状態を管理しているイメージです。
Reduxは下の図のような 一方向のフローとしての図をよく目にしますが↑の図のほうが実体に則している気がして私にはしっくりきました。
https://github.com/facebook/flux/tree/master/examples/flux-concepts
ビューは2種類
ビューは コンテナ と コンポーネント の2種類があります。
- コンテナ(器)
- ストアからコンポーネントで利用する state を受け取る
- コンポーネントで使用する action creator のリストを受け取る
- コンポーネント(部品)
- コンテナから state や action のリストを渡され描画する
- マウスクリック等のユーザーイベントがあったら対応する action を発動する (dispatch)
つまり、コンテナが React外部とのインターフェースとなります。
この説明ではトップがコンテナになっていますがトップにコンポーネントがあり、その中に複数のコンテナがあるというパターンもあります。その場合は、各コンテナがそれぞれ自分がもつコンポーネントの描画に必要な状態やアクションをストアから取得します。
全体像をコードつきで
connect
は引数なしとありで挙動が変わるので実はややこしいのですがその辺は次の記事に詳しく書かれています。
qiita.com
実際のコード
Actions
アクションはアクション種別とアクションに付随するパラメータを store に渡すだけです。
app/javascripts/packs/actions/index.js
let nextTodoId = 1
export const addTodo = text => {
return {
type: 'ADD_TODO',
id: nextTodoId++,
text
}
}
export const toggleTodo = id => {
return {
type: 'TOGGLE_TODO',
id
}
}
export const enableUI = () => {
return {
type: 'UI_ENABLE'
}
}
export const disableUI = () => {
return {
type: 'UI_DISABLE'
}
}
Reducers
Reducer は状態を新しく生成するだけのpureな関数です。引数で与える state が初期状態となります。
ポイントは
- 元の state を編集せず 新しい state を返すこと
です。元のstateの値を編集してもビューは更新されないので要注意です。
app/javascripts/packs/reducers/todos.js
const todos = (state = [{id:0, text:"first task (click me)", completed:false}], action) => {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
{
id: action.id,
text: action.text,
completed: false
}
]
case 'TOGGLE_TODO':
return state.map(todo =>
(todo.id === action.id)
? Object.assign({}, todo, {completed: !todo.completed})
: todo
)
default:
return state
}
}
export default todos
app/javascripts/packs/reducers/settings.js
const settings = (state = { enabled: true }, action) => {
switch (action.type) {
case 'UI_ENABLE':
return { enabled: true }
case 'UI_DISABLE':
return { enabled: false }
default:
return state
}
}
export default settings
app/javascripts/packs/reducers/index.js
import { combineReducers } from 'redux'
import todos from './todos'
import settings from './settings'
const todoApp = combineReducers({
todos,
settings
})
export default todoApp
combineReducers
で todos と settings の2つの partial state をグローバルな state として束ねます。このグローバルな state を store に渡します。
Containers
app/javascripts/packs/containers/App.js
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { toggleTodo, addTodo, enableUI, disableUI } from '../actions'
import TodoList from '../components/TodoList'
const mapStateToProps = state => {
return {
todos: state.todos,
enabled: state.settings.enabled
}
}
const mapDispatchToProps = dispatch => {
return {
onTodoClick: id => {
dispatch(toggleTodo(id))
},
addTodoClick: text => {
dispatch(addTodo(text))
},
toggleUI: checked => {
checked ? dispatch(enableUI()) : dispatch(disableUI())
}
}
}
const App = connect(mapStateToProps, mapDispatchToProps)(TodoList)
export default App
mapStateToProps
関数でグローバルなstateから必要な partial state を取り出し、props
オブジェクトにマッピングしてコンポーネント(TodoList)に渡します。
同様に mapDispatchToProps
関数で action creators のうち必要な action を props
にマッピングしてコンポーネント に渡します。
コンポーネントからは state.settings.enabled
は this.props.enabled
としてアクセスできます。
onTodoClick
関数は this.props.onTodoClick(id)
として呼び出せます。
Components
app/javascripts/packs/components/TodoList.js
import React, { Component } from 'react'
import { render } from 'react-dom'
const Todo = ({onClick, completed, text }) => (
<li
onClick={onClick}
style={{
textDecoration: completed ? 'line-through': 'none'
}}
>
{text}
</li>
)
const AddTodo = ({ onClick, enabled }) => {
let input
return (
<div>
<form
onSubmit={e => {
e.preventDefault()
if (!input.value.trim()) {
return
}
onClick(input.value)
input.value = ''
}}
>
<input
ref={node => {
input = node
}}
/>
<button type="submit" disabled={!enabled}>
Add Todo
</button>
</form>
</div>
)
}
const TodoList = ({ todos, enabled, onTodoClick, addTodoClick, toggleUI }) => (
<div>
<AddTodo onClick={addTodoClick} enabled={enabled} />
<ul>
{todos.map(todo => (
<Todo key={todo.id} {...todo} onClick={() => { if (enabled) { onTodoClick(todo.id) } }} />
))}
</ul>
<input type="checkbox" checked={enabled} onChange={(e) => toggleUI(e.target.checked)} />UI Enabled
</div>
)
export default TodoList
コンポーネントは props
、ここでは { todos, enabled, onTodoClick, addTodoClick, toggleUI }
のオブジェクトをコンテナから受け取って描画とイベントバインディングを行います。
ユーザーがクリックしたらイベントに紐付けられた action が発動します。
今回は1つのファイルにTodoやAddTodoなどのサブコンポーネントを含めていますが通常は別ファイルで定義します。
プロダクトレベルのアプリケーションに向けて
プロダクトレベルのアプリケーションを実装するときはさらに
- 非同期処理(API叩くときetc..)
- ルーティングによるページ遷移
- Global state と Local state の共存
を考えていく必要があります。
私も今まさに勉強中なので詳しくは書けませんが勉強した範囲でポイントとなりそうなことを書いておきます。
非同期処理
非同期処理のポイントは
- データをAPIから取得するアクション
- APIから取得したデータを state に反映するアクション
を分けて実装することです。
具体的なコードは以下のような感じになります。(react-thunkを使います)
export const addTodo = (id, title) => {
return {
type: 'ADD_TODO',
id,
title
}
}
export const createTodoAsync = title => {
return (dispatch, getState) => {
const _todo = { title: title, completed: false }
fetch('/api/todos', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(_todo) })
.then(response => {
return response.json()
})
.then(data => {
dispatch(addTodo(data.id, data.title));
})
}
}
ルーティング
複数のページをもち、ページごとにビューを切り替えたいときはルーティングを行います。
ルーティングは react-router-dom というパッケージを利用します。
基本的な使い方は
medium.com
が参考になります。
import { BrowserRouter as Router, Route } from 'react-router-dom'
document.addEventListener('DOMContentLoaded', () => {
ReactDOM.render(
<Provider store={store}>
<Router>
<Route exact path="/" component={MainPage} />
<Route path="/contact" component={ContactPage} />
</Router>
</Provider>,
document.body.appendChild(document.createElement('div')),
)
})
のようにURLのパスに応じて表示するコンポーネントが選択されるようになります。
また、action(ビュー外)からページ遷移する方法は少しトリッキーになっていて以下が参考になりました。
Local stateとの共存
Redux ではアプリケーションの状態はすべてストアで一元管理することになっています。しかし、そうなると特定のコンポーネント内でしか使わない state もストアで管理することになりストアが肥大化、複雑化しすぎるという問題が出てきます。
そこで Reduxで管理するグローバルな state と各ビュー内のローカルな state を分けて管理するというやり方も考えていく必要があるでしょう。
made.livesense.co.jp
Rails + React + Redux 導入方法
最後に Rails で React を使う方法を紹介します。
RailsにReactを組み込むこと自体はかなり簡単でものの数分で準備できちゃいます。
注意点としては
- yarnをインストールしておくこと (0.20.1+)
- Node.jsのバージョンが古いとwebpackerがインストールできないのでバージョンを上げておくこと (6.4.0+)
ぐらいです。
手順は
React込みのアプリを作成する
$ rails new --webpack=react myapp
適当なページ作る
$ rails g controller page hello
layoutファイル修正 (app/views/layouts/application.html.erb)
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
サンプルのjsxを組み込む (app/javascripts/pack/application.js)
require('./hello_react.jsx') // 追加する
ビルド
$ bin/webpack
サーバーを起動してブラウザで表示 (/page/hello)
2-3分でHello Worldまで行き着けます
これだけだとreduxは使えないので 次に reduxで必要なパッケージをインストールします。
$ bin/yarn add redux
$ bin/yarn add react-redux
環境構築は以下のページを参考にしました。
あとは app/javascripts/packs/ 以下に作りたいアプリケーションを実装していくだけです。
Happy coding ! ┗|l`・ω・´l|┛