背景:React-Reduxが全然わからないのでちゃんと勉強した
開発現場にReact-Reduxを導入しておきながら、チーム全員が「 とりあえず動くと思うから実装しようぜ 」という状態だったので、本腰入れてドキュメント読みました。前回の記事の内容を把握したら一気に見通しが良くなると思ったので、説明します。色々なサイトやドキュメントは明らかに冗長な説明多いので、極限までエッセンシャルを絞って説明することで、ゼロ知識からでもある程度、理解できるレベルの説明に落とし込むことに挑戦しました。うちの開発チームで知見として残すために作成したものですが、需要がありそうかなと思ったので、公開します。需要がなければすみませんでした。おかしな点があれば、まさかりお待ちしております。
Caveats
Reduxが理解できていないと、React-Reduxの必要性が理解しにくいため、Reduxがなんなのか不安な方は、前回の記事や、他の記事で予備知識を補強しましょう。
なお、Reactの説明は省くので、Reactがわからない方は、別途何かの資料を参照してください。Reactについても書きたくなったらそのうち何か書くかもしれません。
TL;DR
結論だけ先に言うと、React-Reduxの役割は、ReactComponentから、ReduxStoreのstateとdispatchを呼び出せるようにして、Reduce時のsubscribeに当たるイベントで、stateのonStateChangeをListenしてReactComponentのViewにstateのパラメータをセットして、変更を伝搬する役割と、ユーザーの入力等のイベントでReactComponentから得られたデータによってdispatchを行うことでstateを変更することです。
この説明だけで「よっしゃ解ったぜ」ってなった人は、この先の説明を読まなくても、ガンガンReact-Reduxを使っていけるのでこの先の文章を読む必要はないでしょう。これからの説明で、以上の結論が違和感なく飲み込んでもらえるようになることが、今回までの一連の記事の目標です。
Reduxの思想に感動して、React-Reduxの作りの必然性を理解できたアハ体験をチームメンバから皆さんにも追体験してもらうことが目的なので、記事を読んでこう説明した方が良いよ、とか全然わからなかったとか思われた方がいらっしゃれば遠慮なくまさかり下さい。全然解らんって言われたら悲しい顔をします。
Reactのコンポーネントのパターン
Redux共作者のDan Abramov先生は、別にReactの厳密な決まりではないが、デザインパターンとして以下の二種類のComponentの使い分けをすると、綺麗な実装ができるとおっしゃられています。ただ、サイズが小さいコンポーネントなどは、一々2つに分ける方が管理が煩わしくなることがあるため、ケースバイケースで切り分けるのがよいです。
Presentational Component
主にViewの責務を果たすのみのコンポーネントです。
まさにデータを渡されたらそれを表示するだけと言った感じのコンポーネントです。
Container Component
主にロジックとデータのキャッシュと受け渡しの責務を持つコンポーネントです。
Container Comopnentは共通化しやすい
以上のロジックとビューのコンポーネントを分離するのは非常に合理的なのですが、共通のロジックが共通だったり、データが共通だったりするコンポーネントに対して、いちいち、Containerコンポーネントを挿入するのは手間だったり、重複するなどしてコードが肥大化する原因になったりします。
このContainer部分のデータ変更ロジックとデータをReduxで実装してしまい、stateの中にあるデータをあらゆるReactComponentから使用出来るようにしてしまえば、ユーザーにデータを表示するために個別のReactComopnent側で最低限必要となる実装は、stateの選択と、viewへのstateの割り振りだけになります。また、ユーザーからインタラクティブに受け取ったデータでstateで書き換えたい場合、そのデータを更新できるようにReactComponentからdispatchが呼び出せるようにすれば、Reduxのフローから外れずに、stateの変更をReactComponentを介して行うことが出来るようになります。また、Reduxによってstateに関わるロジックがプロパティ毎にReducerで全部管理されている事をチームで知ってさえいれば、自分の実装担当範囲で、機能追加のために変更が必要になったstateのメンバ変更ロジックが実装されているかどうか確認がしやすくなりますので、既に誰かが実装したロジックを重複して実装してしまうという有りがちなトラブルを防ぎやすくなります。
React-Reduxの思想
React-Reduxは、connectという関数を使って、自分たちが実装したReactComponentに、対象のstoreから、stateとdispatchをインジェクションし、subscribeで、stateの変更をviewにセットする、ReactComponent上のイベントから、dispatchを行える仕組みを提供します。
基本的に、ReactとReduxは互いに疎結合なライブラリであり、Reduxを導入することで、Compoentに変更を加える必要が出てくるような実装はアンチパターンであり、ReduxからstateをPropsに渡すとしても、親コンポーネントからデータをPropsに渡すとしても再利用が可能な状態を保つ事を考えるべきです。この考え方が抜けていると、ReactCompoentの中にRedux依存のコードを書いてしまったり、必要以上にコンポーネントの再定義を行ってしまう可能性があります。この記事の後半で、設計によって、コンポーネントの扱いやすさがどう変わるのかを説明します。
Provider of React-Redux
Providerは、ReactのAPIにも存在し、セットされたその配下のコンポーネントにおいて、セットされたデータにアクセスできるようにします。React-Reduxの、ProviderはReact標準のProviderに機能拡張し、後述のconnectメソッドで生成されるHigh-Order Component(HOC)にstoreをインジェクション出来るようにしています。以下のようにRootComponentにProviderにstoreをセットすることで、それよりも深いコンポーネントに対して、storeからstateや、dispatchを提供できる状態にします。
import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import Button from './components/Button'
import rootReducer from './reducers'
const store = createStore(rootReducer)
render(
<Provider store={store}>
<Button />
</Provider>,
document.getElementById('root')
)
connect of React-Redux
connectの主な役割は、Providerにセットされた、Redux Storeのstateの読み取りと、dispatchによってReducerを実行出来るようにすることです。また、connectメソッドは第一引数にはmapStateToPropsという関数を指定し、第二引数にはmapDispatchToPropsという関数を指定します。この2つの引数の名前は、ただ単にその関数の役割を表しているだけなので、無名関数や別の名前の関数を引数にしても実行できますが、慣例的に関数名を合わせておくのが良いでしょう。
mapStateToProps
mapStateToPropsに設定された関数の第一引数にはstoreからstate、第二引数からはComponentからPropsが受け取れます。mapStateToPropsはReducerのdispatchを完了後、stateをPropsに受け取る処理を定義ができるため、このstateをPropsにMappingし、このPropsの値をViewにセットすることで、Stateの変更に合わせてViewの変更を追従させる事ができるようになります。また、この処理は、ただ単にstateの値を受け取りだけでなく、stateの値を使って、stateのためのデータを加工して同じkeyに設定したり、別のkeyに設定してPropsに渡すことも出来ます。stateのためのデータを加工して値を設定したプロパティを計算プロパティとよびます(以下のageWithPrefixが一例です。)。前回の記事で説明したSubscribeイベントでpropsにstateをセットしている動作とほぼ一緒ですが、Stateの変更がない場合は更新処理に移行しないなどの最適化が施されています。以下、stateを前回の例同様に、
{
person: {
name,
age
}
}
というデータでおいた時のmapStateToPropsの実装例になります。
const mapStateToProps = (state,OwnProps) => ({
...state,
ageWithPrefix: state.person.age + '歳'
})
/** 以上のmapStateToPropsでPropsにマージされるオブジェクト
* {
* person: {
* name,
* age
* },
* ageWithPrefix
* }
* /
また、関数を戻り値にすることで、変数や関数をComponent毎にbindすること出来ます。
const mapStateToProps = (state,OwnProps) => {
const bindMember = OwnProps.someNumber
return (state) =>({
...state,
ageWithPrefix: state.person.age + '歳',
bindMember
})
}
/** 以上のmapStateToPropsでPropsにマージされるオブジェクト
* {
* person: {
* name,
* age
* },
* ageWithPrefix,
* bihdMember
* }
* /
stateの変更が一部であっても、mapStateToPropsが実行されるため、計算プロパティなどを使用していると変更されていないstateの値についても毎回計算プロパティの更新処理が走ります。今回の例ではageの後に歳という文字を挿入するだけなので重い処理ではありませんが、一回の値の計算に時間がかかるような処理の場合、何も工夫を入れないとパフォーマンスのネックになっていきます。これを防ぐため、メモ化(memoization)という処理を入れて、変更されていないプロパティに対しては前回の処理結果をそのまま取得して使用するようにすることで、パフォーマンスを改善ができます。メモ化については、この記事に書きました。
mapDispatchToProps
mapDispatchToPropsには、ObjectとFunction二種類の引数がとれます。どちらの場合でもその戻り値はActionまたは、Actionを返すFunction(mapStateToPlops同様クロージャで個別のComponent毎に値をbindしたい場合)で定義され、connectメソッドによって、Objectのkeyが、Propsにマッピングさます。これによって、このObjectのkeyに設定されている関数をReactComponentの内部の関数として呼び出すことが出来るようになります。また、この関数はdispatchを実行できるため、ReactComponentのイベントにこれらのコールバックに設定することで、stateの変更をReactComponentから行うことが出来るようになります。
引数がObjectの場合
Objectの場合は、keyにActionCreatorの名前を、functionにActionCreatorの関数の実体を定義します。この時、ActionCreatorは、自動的にdispatchの引数に設定されるように以下のように書き換えられます。(つまり、自動でboundActionCreatorに書き換えられ、このオブジェクトの関数の実行はdispatchまで行ってくれるようになります。)
const mapDispatchToProps = {
setPersonName: (name) =>({
type:"SET_PERSON_NAME",
name: "Tkow"
})
}
// connect(undefined,mapDisPatchToProps)(Component)実行後
// props.setPersonName = (name) => dispatch(setPersonName(name))
// と等価になる
引数がFunctionの場合
mapDispatchToPropsに設定された関数の第一引数にはstoreからdispatchメソッド、第二引数からは、ComponentのPropsが受け取れます。この関数の戻り値には、boundActionCreatorが設定されたオブジェクトを与えます。boundActionCreatorはActionCreator内でdispatchメソッドをbindし、dispatchまで行うActionCreatorのことです。よく解らなかった場合は前回の記事の発展的なActionCreatorの項目も参照してみてください。Functionとして引数を渡す場合のmapDispatchToPropsの指定の仕方は、以下のようになります。
const mapDispatchToProps = (dispatch, OwnProps) => ({
setPersonName: name => dispatch(setPersonName(name)),
setPersonAge: age => dispatch(setPersonAge(age))
})
この時、第一引数にstoreからdispatchがインジェクションされてくるため、ReactComponentのイベントからdispatchが出来るようになります。
mapStateToPropsからViewにstateの値を反映し、mapDispatchToPropsのboundActionCreatorをイベントハンドラで実行する
以下にmapDispatchToPropsを関数でセットしたboundActionCreatorをComponentのイベントにセットする例を示します。
import React from 'react'
import PropTypes from 'prop-types'
import { setPersonName } from '../actions'
import { connect } from 'react-redux'
const Button = ({ onClick, person }) => (
<div>
<p>
<button
onClick={onClick}
>
Click!
</button>
</p>
name: {person.name} ,age:{person.age}
</div>
)
Button.propTypes = {
onClick: PropTypes.func.isRequired,
person: PropTypes.object.isRequired
}
//Randomで5文字の文字列を生成する
const getRandomName = () => {
const alphabets = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const getChar = () => alphabets.charAt(
Math.floor( Math.random()*alphabets.length )
)
return [...Array(5)].map(getChar).join('')
}
const mapStateToProps = (state, ownProps) => {
return state
}
// Functionのパターン
const mapDispatchToProps = (dispatch, ownProps) => ({
onClick: () => dispatch(setPersonName(getRandomName()))
})
// Objectのパターン
// const mapDispatchToProps = {
// onClick: () => setPersonName(getRandomName())
// }
export default connect(
mapStateToProps,
mapDispatchToProps
)(Button)
以上は、Objectでも、Functionでセットしても、同じ結果が得られます。以下、react-scriptsを使って、上記と合わせることで実行可能なコードを載せます。
import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import Button from './components/Button'
import rootReducer from './reducers'
const store = createStore(rootReducer)
render(
<Provider store={store}>
<Button />
</Provider>,
document.getElementById('root')
)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>React Redux Example</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
import { combineReducers } from 'redux'
import { person } from './person'
export default combineReducers({
person
})
import { combineReducers } from 'redux'
export const person = (state, action) => {
if(!state) return {
name: 'tkow',
age: 12
}
return combineReducers({
name,
age
})(state,action)
}
function name(state='', action) {
switch(action.type) {
case "SET_PERSON_NAME": return action.name
default:
return state
}
}
function age(state=0, action) {
switch(action.type) {
case "SET_PERSON_AGE": return action.age
case "ADD_PERSON_AGE": return state + 1
default:
return state
}
}
export const setPersonName = name => ({
type: 'SET_PERSON_NAME',
name
})
export const setPersonAge = age => ({
type: 'SET_PERSON_AGE',
age
})
export const addPersonAge = age => ({
type: 'ADD_PERSON_AGE'
})
この時、Button.jsは、connectが必要な処理とは元のコンポーネントと完全に疎結合なので、ロジックとビューの処理部分を分けることが出来ます。Reduxのロジック部分を完全に切り出してこれを、ContainerComponentとしてみなすと、connect関数はこのReduxのロジックによって切り出したContainerComponentを、PresentationalComponentに結合させるという見方をすることができます。よって、2つのComponentはお互い実装に干渉せず、切り出すことが出来るため、PresentationalComponentは以下のようにContainerComponentと分離してconnectをContainerComponentから行うことで、デコレータのように付け外し可能でPresentationalComponentはReact-ReduxのComponentだけでなく、親コンポーネントから渡すことで、ただのReactComponentとして使いまわすことが出来ます。
import React from 'react'
import PropTypes from 'prop-types'
export const Button = ({ onClick, person }) => (
<div>
<p>
<button
onClick={onClick}
>
Click!
</button>
</p>
name: {person.name} ,age:{person.age }
</div>
)
Button.propTypes = {
onClick: PropTypes.func.isRequired,
person: PropTypes.object.isRequired
}
import { connect } from 'react-redux'
import { setPersonName } from '../actions'
import { Button } from '../components/Button'
const getRandomName = () => {
const alphabets = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const getChar = () => alphabets.charAt(
Math.floor( Math.random()*alphabets.length )
)
return [...Array(5)].map(getChar).join('')
}
const mapStateToProps = (state, ownProps) => {
console.log(state)
return state
}
// const mapDispatchToProps = (dispatch, ownProps) => ({
// onClick: () => dispatch(setPersonName(getRandomName()))
// })
const mapDispatchToProps = {
onClick: () => setPersonName(getRandomName())
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Button)
import React from 'react'
import { render } from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'
import Button from './container/ConnectButton'
import rootReducer from './reducers'
const store = createStore(rootReducer)
render(
<Provider store={store}>
<Button />
</Provider>,
document.getElementById('root')
)
Reduxと完全に密結合が必要なComponentは、@connectデコレータの方が見通しが良くなります。
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { setPersonName } from '../actions'
const getRandomName = () => {
const alphabets = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const getChar = () => alphabets.charAt(
Math.floor( Math.random()*alphabets.length )
)
return [...Array(5)].map(getChar).join('')
}
export const mapStateToProps = (state, ownProps) => {
console.log(state)
return state
}
// export const mapDispatchToProps = (dispatch, ownProps) => ({
// onClick: () => dispatch(setPersonName(getRandomName()))
// })
export const mapDispatchToProps = {
onClick: () => setPersonName(getRandomName())
}
@connect(mapStateToProps, mapDispatchToProps)
export const Button = ({ onClick, person }) => (
<div>
<p>
<button
onClick={onClick}
>
Click!
</button>
</p>
name: {person.name} ,age:{person.age }
</div>
)
Button.propTypes = {
onClick: PropTypes.func.isRequired,
person: PropTypes.object.isRequired
}
ここまでの例では、ComponentがjsxのElementが返される関数になっていますが、このReactComponentの表現方法をStateless Fuctional Componentと言います。今回の例でClass React.Componentを使わないのは、Stateless Fuctional Componentが実体に余計なライフサイクルや状態を定義する必要がないためです。Stateless Fuctional Componentの第一引数では、Propsがインジェクトされるため、Propsの分割代入によって、親コンポーネントからのProps、connectによるReduxのstoreがマッピングされたPropsどちらからでも、パラメータを受け取れます。よって、親ComponentからのProps、connectのマッピングによるProps、どちらにも共通のProperty名と、イベントハンドラ名をつけておくと、PresentationalComponentはどちらのComponentでも再利用することが出来ます。例えば、以下のようにContainerComponentと、PresentationalComponentに分けた時のButton.jsコンポーネントは、実装を全く変えなくても、jsxタグに親からPropsを渡す事ができます。
import React from 'react'
import PropTypes from 'prop-types'
export const Button = ({ onClick, person }) => (
<div>
<p>
<button
onClick={onClick}
>
Click!
</button>
</p>
name: {person.name} ,age:{person.age }
</div>
)
Button.propTypes = {
onClick: PropTypes.func.isRequired,
person: PropTypes.object.isRequired
}
//親コンポーネントでの実装
<Button onClick={() => this.person.name=getRandomName()} person={this.person} >
この記事の前半で、
この考え方が抜けていると、ReactCompoentの中にRedux依存のコードを書いてしまったり、必要以上にコンポーネントの再定義を行ってしまう可能性があります。この記事の後半で、設計によって、コンポーネントの扱いやすさがどう変わるのかを説明します。
と書きましたが、これは、つまり、以上のようにReduxとReactComponentの依存関係を排除してPropsに渡す値を付け替え可能な状態に保つように設計しないと、Component側で、Reduxの処理のために用意する実装が増えたり、Redux側でComponent側のための処理を増やしたりと、無駄な再実装を行っている事があるかもしれないということです。
どの値が、他の構造と入れ替え可能か、あるいは、付け外し可能かという感覚はComponent指向だけでなく、色々なデザインパターンを考える上で、重要なので、もし苦手な方がいらっしゃれば、この感覚を是非掴めるまで色々実装して試してみてください。
mergeProps
connect関数は第三引数にmergePropsと呼ばれる関数引数を取ることが出来ます。これも例の如く、単なる名前付き引数の関係ですので、関数名をmergePropsにする必要はありません。
mergePropsの引数には、(resultMapSteateToProps,resultMapDispatchProps,OwnProps)を取り、戻り値をObjectで返す必要があります。この戻り値のObjectが、connectされたComponentのPropsにmappingされることになります。defaultでは、Object.assign({}, ownProps, resultMapSteateToProps, resultMapDispatchProps)というように、親から渡されるPropsの値よりも、connectで設定されるstate,stateよりもdispatchが優先されます。これによって、Overrideされると困るパラメータを再設定したり、本来のPropsに渡された処理をdispatchのコールバックに設定するなどの用途のために用意されています。
Option
connectの第4引数には、Optionを設定できます。このOptionは基本的に、mapStateToPropsの更新条件を変更したい場合と、storeを複数所持している場合という特殊なケース以外で使うことはないと思いますが、説明しておきます。
- pure:Boolean - falseにすると、stateの変更があったかどうかに関わらず、毎回mapStateToPropsのSubscribe更新が走るようなります。デフォルトはtrueです。
- areStatesEqual,areOwnPropsEqual,areStatePropsEqual,areMergedPropsEqual:function - これらのオプションで指定される条件が全てtrueのときに変更をmapStateToPropsを実行しません。それぞれ、storeのstate、component初期のprops、propsにmappingしたstate、MergeされたPropsの値をdispatchの前で比較して、変更がなければ、それぞれtrueを返します。
- storeKey:String - storeを2つ以上保持している場合、どのstoreからstateとdispatchを受け取るかをstringで指定する。
以上で、React-Reduxの主要な機能についての説明を終わります。React-ReduxはProviderとconnect関数の仕様のみ把握しておけば、実際に使うときには困りません。が、残りのAPIについても軽く触れようと思います。
connectAdvanced
connectAdvancedは、mapStateToProps、mapDispatchToProps、mergePropsの実装定義がない状態で、High-Order Component(元となるComponentComponentをWrapしてに機能拡張したもの)を作成するための関数です。selctorFactoryとその中で、任意に使えるFactoryOptionsという2つの引数をとれます。FactoryOptionsは、自分の実装したselectorFactoryで任意に使用できる他、defaultのコンポーネントを操作するデフォルトのオプションもあります。(ここでは、紹介しません。詳しくは、公式のリポジトリを御覧ください。)connectメソッドの内部でも、このメソッドが使用されており、第一引数には、store、state、のイベントハンドルを行うための関数が、第二引数にはmapStateToProps、mapDispatch、mergeProps、connectメソッドのOptionが、FactoryOptionsとして展開されます。つまり、FactoryOptionsに入れた引数は全て、selectorFactoryで受け入れて任意の処理を行えるため、connectメソッドの内部では、このselectorFactoryメソッドの中で、state、Propsの変更検知をおこない、コールバックが発火する仕組みを作っているのです。このようにconnectAdvancedメソッドは自分で、storeやstateの扱い方を定義し、オリジナルのHigh-Order Componentの操作を定義するためのメソッドです。上級者向けかつ、実装の幅が広いため、あまり使うことはないかもしれません。
createProvider
引数にStoreのIDを入れることで、storeの使い分けができるProviderを作成します。Providerがネストされていた場合など管理が困難になるため、必ずKeyは重複しないようにしましょう。公式でも以下のようにconnectのstoreKeyのデフォルト値を変更しておき、import先のファイルだけで、呼び出されるstoreが明示的にされていることを推奨しています。(ただし、複数にstoreを分散させるのは公式には推奨はされていません。)
import {connect, createProvider} from 'react-redux'
const STORE_KEY = 'componentStore'
export const Provider = createProvider(STORE_KEY)
function connectExtended(
mapStateToProps,
mapDispatchToProps,
mergeProps,
options = {}
) {
options.storeKey = STORE_KEY
return connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
options
)
}
export {connectExtended as connect}
まとめ
Providerとconnectの仕組みを知って使いこなせれば、怖くないです。
connectAdvancedは怖いです。