はじめに
React + ES6 + Webpack で JSON Visual Editor を作ってみるで、簡単なアプリを React で作ってみましたが、機能を追加して構成が複雑になってくるとデータのハンドリングが煩雑になってきます。そこで、Redux を導入してデータを一元管理し、View とデータを分離して開発しやすくします。
Redux の導入
UI コンポーネントが増えてきたり構造が複雑になってくると、ルートのコンポーネントで管理しているデータを操作するメソッドを props で渡したり、コンポーネント間で state のデータをやり取りするのが面倒になってきます。また、全然依存がないコンポーネントがデータやメソッドをバケツリレーしなければならないのも面倒です。
これを解決するために、Redux というフレームワーク導入します。これにより、各コンポーネントの state で管理していたデータを一元管理して、そのデータを操作するメソッドをどのコンポーネントからでも呼び出せるようにして、コンポーネント間の依存関係を減らします。
多少トリッキーで記述も増えるので、小規模なアプリケーションやピュアに React を学びたい場合には適していないと思います。ただ、大規模なアプリや、今後複雑になることがわかっている場合は、初めからこのようなフレームワークを導入した方が良いかもしれません。今回途中から Redux を導入したのですが、理解して、コードを置き換えていくのに結構苦労しました。
Redux を理解するには、本家のドキュメント(翻訳)も良いですが、私はこの記事が、ソースコードも公開されていてわかりやすかったです。
ReduxとES6でReact.jsのチュートリアルの写経 - undefined
ドキュメントもいろいろあるので、ここでは必要なことだけに絞って手順を書いてみます。
なお、導入前と導入後の変更点はこちらで確認できます。
react-redux のインストール
React 向けのライブラリである、react-redux をインストールします。
$ npm install --save react-redux
Store と Provider の準備
データを一元管理する Store と、React とのインタフェースとなる Provide を準備します。これらは今後出てくることはないので、とりあえずおまじないだと思って実装すればいいでしょう。
import React from 'react';
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import App from './App'
import reducer from './reducers'
// Instance the store object
const store = createStore(reducer)
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.querySelector('#myApp')
)
このファイルはもともとエントリーポイントとなっているファイルで、ルートのコンポーネントである <App />
を定義していたものです。このコンポーネントは App.jsx に移して、この index.js から呼び出すように変更しています。
この時、Redux が提供している <Provider />
コンポーネントで <App />
を包んで、引数に store
を渡しています。この store
がまさしく Store で、データを一元管理しているオブジェクトとなります。
ちなみに、この store
を直接触ることはありません。createStore()
で渡されている reducer
が Store へのデータの橋渡し役となります。
Action の定義
次に、ユーザーによって何か操作された時などに実行するメソッドを下記のように Action として定義します。
export const updateText = (newText) => {
return {
type: 'UPDATE_TEXT',
newText
}
}
この updateText()
は、テキストを引数 newText
で更新するアクションを定義するものです。これは単純にオブジェクトを返すだけの関数で、実際に処理を行うことはしません。このオブジェクトは必ず type
を持ち、アクションを特定する識別子を指定します(ここでは文字列を指定していますが、アクションごとに定数を定義して、それを指定する方がベターです)。
定義したアクションは、Store の dispatch() を経由して呼び出すことができ、Reducer に渡されます。Store の状態と渡された Action の内容に応じて、Reducer の中で Store のデータを操作します。
なので、Action はあくまで定義のみで、実際の処理を記述するのは Reducer となります。
Reducer の実装
Reducer は、現在の Store のデータと呼び出された Action を基に、新しいデータを生成して Store に渡すものです。
Reducer の実態は state と action を引数に持つ関数で、Redux によって呼び出されます。基本形は、下記のような感じです。
// Initial data
const initialState = {
data: null,
text: '',
...
}
// Reducer
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'UPDATE_TEXT':
...
return newState
default:
return state
}
}
第2引数で渡される action
には type
が含まれていて、これで Action が呼び出されたかを判断し、各アクションごとの処理を記述します。
第1引数で渡される state
は、Store で管理されている現在のデータが渡されます。これを処理して新しい state を返します。
気をつけるべきことは、この state と action 以外の値や状態で、Store に渡す state の値が変わらないようにすることです。なので、Reducer の中でグローバル変数を参照したり、API を呼んだりしてはいけません。これによりテストがしやすくなります。
たとえば、クリアボタンを押した時にテキストをクリアする処理はこんな感じです。現在の state に空のテキストと null のデータを上書きして返しています。ES6 の Object.assign()
が便利ですね。
case 'CLEAR_TEXT':
return Object.assign({}, state, {
text: '',
data: null
});
もし何も変化がない場合には、引数の state
をそのまま返しましょう。
default:
return state
ちなみに、起動時に Reducer が実行される際、デフォルト引数の initialState
がそのまま Store に渡されて初期化されます。初期値はここに記述します。
connect でコンポーネントと Store をつなぐ
React コンポーネントから Action を呼び出して、Store の内容を反映させるために、connect()
を使います。
公式ドキュメントには、「Connects a React component to a Redux store.」とあります。まさに名前の通り、React のコンポーネントと Redux の Store を「つなぐ」役割なのですが、実装はちょっとわかりにくいです。ただ、あまり本質的なところでもないので、こんなものだろうと割り切ってしまう方が良いかもしれません。
connect の記述
これまで、React のコンポーネントは下記のように export していました(TextArea クラスを例にしています)。
export default class TextArea extends React.Component {
これは、export とクラス定義を一度に行っていたのですが、見やすくするために下記のように分離します。
class TextArea extends React.Component {
...
}
export default TextArea
その上で、export の際に connect を使ってコンポーネントと Store を下記のようにつなぎます。
// export default TextArea
export default connect()(TextArea)
これで終わりではなく、コンポーネントがどのアクションを実行して、どの値を受け取とるかを指定する必要があります。詳細は react-redux の API 仕様にあるのですが、簡単に説明します。
Store のマッピング
connect()
の第1引数には、Store で管理されているデータを受け取り、このコンポーネントの this.props
にマッピングする関数を渡します。
const mapStateToProps = (state) => {
return {
// mapping state.text to this.props.text
text: state.text
}
}
Action のマッピング
第2引数には、Action に応じて処理を実行するための dispatch
関数を受け取り、コンポーネントの this.props
内から呼び出せるようにマッピングする関数を渡します。
import { updateText } from './actions'
const mapDispatchToProps = (dispatch) => {
return {
// mapping updateText() action to this.props.updateText()
updateText: (text) => {
dispatch(updateText(text))
}
}
}
ちなみに、dispatch
は、Store にアクションを渡して、Reducer を呼び出すためのものです。
connect() に渡す
これらのマッピング関数を、connect() に渡します。
export default connect(mapStateToProps, mapDispatchToProps)(TextArea)
これで、Store の値やアクションを this.props
で扱えるようになります。複雑ですが、おまじないだと思って記述すればいいかと思います。
コンポーネントでの記述
上記のマッピングを行うことで、this.props
でアクセスできるようになったので、これまでと同様の記述でコンポーネントを実装します。
onChange(event) {
this.props.updateText(event.target.value) // <= Call dispatch
}
render() {
return (
<div>
<textarea id="json-text"
value={this.props.text} // <= Access to the Store data
onChange={this.onChange}
ref="jsonText"></textarea>
<ControlsArea />
</div>
);
}
これまでコンポーネント内で管理していた this.state
は、Store で一元管理するので、上記のように this.props
にマッピングして Store にアクセスするようにします。なので、this.state
は不要になります。
また、親コンポーネントから子コンポーネントに値を渡す必要がなくなるため、属性で値を渡す記述は不要になります。
//<TextArea data={this.props.data} updateData={this.props.updateData} />
<TextArea />
まとめ
これで、Redux への置き換えができました。まだ、効率のよい書き方とかあるかもしれませんが、見つけたらこちらに追記していきたいと思います。
今後、機能を追加したり UI 構成を変更する場合にも、コード量が少なく済むのは非常に嬉しいですし、Reducer で記述しているロジックのテストが書きやすくなったは大きなメリットです。導入時の理解や実装は多少面倒ですが、アプリケーションの規模が大きくなる場合には、Redux を導入してみるといいと思います。