tl;tr => ちゃんと考えるかライブラリに任せよう。
現場で、ある程度の規模がある画面を作る場合は、基本的にはReact+Reduxを利用するようにしてみています。最近とみに思いますが、この辺の流れがあまりにも早すぎて、あまり良くわかってない人に伝えると(゚Д゚)ハァ?ってなるのは勘弁して欲しいです。
ちょっと離れてて久しぶりに色々見たら、自分が採用したライブラリがオワコンとかなってて(´・ω・`)になったり。
ES2015(ES.nextですか)は年ごとに仕様が変わっていくということもあり、正直色々と流動性が高すぎるような気がしてます。
それは置いておいて。Redux界隈はかなりの数のプラグインやユーティリティが作成されており、一つのエコシステムを形成しています。その中(Reduxのユーティリティ)でも一番有名なのは、react-reduxだと思います。なんつっても公式ドキュメントでも書いてますし。
https://github.com/reactjs/redux
https://github.com/reactjs/react-redux
http://redux.js.org/docs/basics/UsageWithReact.html
さて、Reactのコンポーネントは、というよりもコンポーネントというものは基本的に相互に独立しているべきです。コンポーネントのコンポーネント、というものでも基本的にはそれは変わらないです。
Reduxでは、さらにそのコンポーネントが持つ、 状態と反映 と 処理 をそれぞれreducer/store、ActionCreator として分離しています。(これは私の理解なんで、違うかもしれません。詳しくは上記のドキュメントを)これらを分離することで、それぞれの独立性が更に高まると同時に、ユニットテストなども書きやすくなり、また、状態をReducerとして分離したことで、状態の組み合わせを変更することも可能になります。
reducer/ActionCreatorは pureな関数 であることを強制する、というのも大きく、むしろこれで構成するための方法論(とシンプルなヘルパー)を提供している、ということがReduxの意義なのかな、とも思います。
規模が大きい画面を作成するうえでは、これくらい徹底しないとダメなんだなぁ、と、かなりでかい画面を作成して実感できました。まぁ、JavaScriptの性として、それでもちょっと油断すると、すぐに楽な方に流れるんですが。。。
reduxでの「再利用」の意味
公式ドキュメントでも、 Reduxを使った再利用可能なコンポーネント について言及されています。上記のreact-reduxのところにも書かれていますが、 Presentational Components と Container Components と分離させ、 Actionの実行やStoreからのStateの反映などはContainerに、それ以外(スタイルやマークアップ)を Presentational に置く、ということです。
これらを分離し、またPresentational には reduxがあるかどうかを意識させない ことを徹底する、つまり、Presentationalで状態を変更するような処理(ボタン押したとか)を行わせる場合は、Actionを直接実行させずに、外からpropsに渡されたハンドラを実行する、というようにします。
すると、Presentational Componentsは、Propsさえ渡すことができれば、どこでも再利用可能となります。保持する状態やなんやかんやを変更するときは、Container Componentsを新しく作ればいい、ということになります。
react-reduxは、この Container Componentsを作成するための関数を提供するライブラリです。実際、Container Componentsはほとんど同じ形となるため、これはそのようにすべきだと思います。
ですが、これらは全て storeはルートコンポーネントから渡される ことを前提とした作りについてしか言及していません。
// components/App/index.js
import React from 'react';
import { connect } from 'react-redux';
class Hoge extends React.Component {
...
}
// これで、HogeをラップするContainerを自動的に作成
const ConnectedHoge = connect()(Hoge);
export default ConnectedHoge;
// index.js
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import appState from './reducers';
import App from './components/App';
let store = createStore(appState)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
こんな感じです。基本的にStoreは一個、という考え方です。
今やりたい「再利用」
ですが、私が今どうしようかと頭を悩ませているのは、 Reducer/ActionCreator自体が独立している コンポーネントをどう再利用可能にするか、ということです。意味分かんないですね。
端的に言うと、エディタコンポーネント的なものを作ろうとしたとき、 複数のエディタがそれぞれ独立したデータを編集する というようにしたいんですが(そりゃそうだ)、それを一つのStoreの中に配置しようとした時に、 エディタ側は渡されるStoreの構造に依存したくない というのをどう解決しようか、と考えてます。
前節で書いた react-redux では、 connect
関数を利用することで、Container Componentsが、Store内のStateから、実際にPresentationalに渡すためのPropsを構成するための処理などを提供していますが、私がやりたいのは 全てのエディタコンポーネントで別のStoreを持つ ということです。
エディタコンポーネントは、ものにもよりますが、独自の内部状態や、独自のActionがあるはずです。Reducer自体は、ReduxのcombineReducersで合成することは出来ますが、Storeを独立させたいととしたときにその手段を取ることは出来ません。
(要はエディタコンポーネント単体としては、React.renderとして使うことも、Compositeして利用することも出来るようにしたい、でもStoreは常にReact.renderでStoreを渡す=エディタコンポーネント独自のReducerから作成されるStoreを使いたい、というわがままです)
実際、こういう感じ(多分)の疑問点は、Reduxのissueでもディスカッションされており、様々な解決案が書かれています。
Elmのアーキテクチャ使いなよ!とか若干ワクテカなことも書かれています。
解決案を考えてみた
解決するにあたって、要件は以下かなと。
- 外部からStoreは渡さない
-- 初期状態は渡せないと困るので、それは渡せるようにする - 一つの画面に複数のエディタを配置できる
-- それぞれが独立しているということ。 - エディタコンポーネントは reducer/ActionCreatorを使う
- export defaultで返るのは React.Componentを継承したもの であること
-- テンプレートエンジンを使っている場合、これがほぼ前提となりますのでこれは外せません。
見た感じ、Relayとか使えばなんか行けそうな気はしますが、それを導入するってのはなしで。
(https://facebook.github.io/relay/ Relayは夢がありますね)
これを解決する手段として、私の頭では以下のような感じのソリューションが思いつきました。
- シンプルに、エディタコンポーネントのルートとなるコンポーネントでStoreの生成を行い、自分自身をContainerとする
- react-reduxで作ったContainerに対して、propsに 渡したStateから、エディタコンポーネントのStateを取得するための関数 を渡す。
-- Containerは、Stateからラップしたコンポーネントに対して渡すPropsを作るためのハンドラを持てるので、そのなかでこの関数から取り出すようなイメージ
1番目はこういうことです。
import React from 'react';
import {createStore} from 'redux';
import tmpl from './index.rt.js';
import reducer from './reducers/';
import {echo} from './action-creators';
// エディタコンポーネント・・・
export default class Editor extends React.Component {
constructor(props) {
super(props);
// 自分で作っちまえ!
let store = createStore(reducer);
this.store = store;
// 自分で作ったStoreを自分でsubscribeする
this.unsubscribe = store.subscribe(() => this.setState(store.getState()));
this.state = {value: ''};
}
componentWillUnmount() {
// 一応参照は切っとく
this.store = null;
this.unsubscribe();
}
onChange(e) {
this.setState({value: e.target.value});
}
onClick() {
// this.props.storeの代わりにこうする
this.store.dispatch(echo(this.state.value));
}
render() {
return tmpl.apply(this);
}
}
色々ツッコミどころがあるのは全面的に同意しますが、少なくともこれで要件を満たせます。実際、
import React from 'react';
import ReactDom from 'react-dom';
import Editor from './components/editor';
ReactDom.render(React.createComponent(Editor), document.querySelector('#root'));
とすることも、他のコンポーネントの中に入れることもできます。複数のEditorがあっても、それぞれ完全に独立しているため、全く問題ありません。
考えられる問題は?
1番目の解決策について、ちょっと(10分くらい)考えてみましたが、あんまり問題自体は思いつきませんでした。しいて言えば、それぞれが独自のStoreを持ってしまうことによる、メモリ消費量の増大・・・とかですが、別段一つの巨大なStateであるときとそんなに変わらんかなと。
Reducer/ActionCreator自体は全てpureな関数である、ということもあって、それらは同一の実体が共有されても問題はありません。
まぁ、コンポーネントの中に this.store = ...
とかあるのが若干気持ち悪いですが、それは unsubscribe もそうなので、 外から知られなければどうでもいい ということだと思ってます。最悪、普通にメソッドを提供するってのもありだとは思いますし。
2つ目の方法でも、基本的には問題はないと思います。むしろ、Reduxの哲学的にはこっちの方が正しそう。
ただし、 コンポーネントが独自に定義したreducerを持ってくる 必要はあります。また、コンポーネントが独自に定義しているType自体が被るケース、というのもありえます。
例えばAというライブラリが提供するReduxを利用したコンポーネントがあって、ライブラリ自体がreducerを明示的にexportしていない場合、自分でその階層まで行ってrequireなりimportしてくる必要があります。
・・・そんなケースあんのか、っていうのはありますし、そもそもそんなことをやってないから気にしなくていいんじゃないかとは思いますが。
まとめ
- redux(じゃなくてもその考え方)はぜひ導入すべき
-- ElmとかHaskellとかの純粋関数型と謳ってる言語では、むしろこの方法が一番自然に書ける - react-reduxも使おう。
-- それなりの規模になっても平気、なはず(要出典)(react-reduxで生成されるContainerは効率的に更新できるようにしてくれてる) - 複数のStoreを持ちたい、となったときはよく考えよう
−− reduxの作者も、 複数のStoreが必要になるってのはおかしいだろ みたいな発言をしてます(うろ覚え・・・) - 独立したreducer/ActionCreatorとStoreを持つコンポーネントを作りたい、となったらもっとよく考えよう
-- 本当に再利用する必要がないんだったら、普通にreact-reduxの機能使って、Stateの中から対応するものを引っ張ってくればOK
-- ただし、全部が全部ルートのReducerに投げ込むと収拾がつかないから、 mini-Reduxなアプリケーションを作ったほうがいいんじゃないか、という問題提起もあります。(1番目の解決法として上げたやつが丁度これですね)
このへん、色々とReduxが利用されてきて、色々とぶつかっている点なんだと思います。なにかいいアイディアがありましたらぜひ教えてください・・・。もしくは参考に上げたissueとかに書くってのもありかと。
参考
実際にこの問題について議論していたIssue
https://github.com/reactjs/redux/issues/1098
↑のissueの内容を引き継いで、react-redux側で継続している議論(というか方法論の収集?)
https://github.com/reactjs/react-redux/issues/278
Reducerだけども、同じアクションで同時に動かないような黒魔法を提供するライブラリ。reduxの作者は好みじゃないようです。
https://github.com/erikras/multireducer
若干私の1番目の方法に近い案。本人が crazy ideas とか言ってるけど・・・。
https://github.com/reactjs/redux/issues/1385#issuecomment-184805927