はじめに
本記事では、ReactでReduxを使うためのライブラリを紹介します。このライブラリは、先日紹介したreact-trackedのRedux版とも言えます。
reactive-react-reduxとは
リポジトリはこちらです。
https://github.com/dai-shi/reactive-react-redux
Reduxが公式に提供しているReact向けライブラリは、react-reduxと呼ばれます。reactive-react-reduxは非公式ライブラリで、公式ライブラリの代わりに使えるものです。ただし、Hooks APIしか提供しません。
公式のReact ReduxのHooks APIとの差分は2点あります。
- useTrackedStateというhookを提供する
- stateを直接contextに入れる方式を採用する(storeではなく)
今回は前者について説明します。
そもそも課題はなにか?
実はこのライブラリを作ったのは、connectへの不満からでした。Reduxはもっとシンプルに使えるものであるはずと思い、mapStateToPropsを排除したAPIを提供したかったというのが動機です。
現在では、useSelectorというhookが公式ライブラリから提供されているので、その差分で説明したいと思います。
例として、storeのstateが次のような形をしているものを考えましょう。
const state = {
user: {
lastName: 'React',
firstName: 'Hooks',
age: 1,
},
color: 'white',
};
このstateを使って、コンポーネントでfirstNameとlastNameを表示してみましょう。useSelectorを使った典型的な書き方は次のようになります。
const Component = () => {
const firstName = useSelector(state => state.user.firstName);
const lastName = useSelector(state => state.user.lastName);
return (
<div>
<div>First Name: {firstName}</div>
<div>Last Name: {lastName}</div>
</div>
);
};
connectに慣れている方は、useSelectorが複数あることに慣れないかもしれませんが、これがオススメされる書き方です。
仮にここで、
const { firstName, lastName } = useSelector(state => ({
firstName: state.user.firstName,
lastName: state.user.lastName,
}));
のように書いてしまうと、動作はしますが、望まない形になります。望まない形とは、state.colorだけに変更があった場合でも、このuseSelectorはコンポーネントを再renderすることを指します。これが起こるのは、useSelectorに指定している関数が毎回新しいオブジェクトを生成するためです。
これを回避する(オススメはuseSelectorを分けることですが、それができない場合に)方法は、公式ライブラリでは2つあります。
- equalityFnを第二引数に指定する
- memoized selectorを作って使う
この辺りが、React Reduxが難しく感じる理由の一つだと感じています。
そもそも、selectorというのは大きいstateオブジェクトから必要なものを選択してくるというAPIとしてはとても分かりやすいものです。そこに、パフォーマンス向上(ここでは無駄なrenderを抑制すること)のために、selectorにreference equality(=参照透過性)の概念を持ち込むことが難しさの原因かと思います。
useTrackedStateを使うとどうなるか?
useTrackedStateを使うと上記のコードは、次のようにかけます。
const state = useTrackedState();
const { firstName, lastState } = state.user;
もしくは、selectorを別で定義したとして、次のようにも書けます。
const selectUserNames = state => ({
firstName: state.user.firstName,
lastName: state.user.lastName,
});
const Component = () => {
const { firstName, lastName } = selectUserNames(useTrackedState());
return (
<div>
<div>First Name: {firstName}</div>
<div>Last Name: {lastName}</div>
</div>
);
};
このように特にreferential equalityを気にせずにselectorを書いたとしても、useTrackedStateにより、無駄なrenderを抑制することができます。
なぜそんなことができるのか?
ReduxメンテナーのMarkのブログ記事では、「Magic?」として紹介されています。reference equalityを意識してselectorを書いている人には、むしろこの挙動が予測不能なものに感じるのかもしれません。
useTrackedStateが動作する仕組みは、Proxyによるものです。
Proxyを使うと、オブジェクトへの操作を追跡(Track)できます。つまり、storeの大きなstateの中でどのオブジェクトプロパティにアクセスしたかを知ることができます。これを利用して、アクセスがあったプロパティに変更があった場合のみコンポーネントを再renderするようにuseTrackedStateが制御しています。
Proxyって遅いんじゃないの?
比較対象を何にするかですが、まず、人が正しくreferential equalityを考慮したselectorを書けるとは限らないということは伝えたいと思います。その場合は、機械がTrackする方が正確になります。もちろん無駄がなく、トータルで速いです。
仮に、完璧なselectorを人が書けたとして、Proxyにはオーバーヘッドがあるのは事実です。簡単な例でベンチマークをした結果を載せます。
ここで比較すべきは、reactive-react-reduxのuseTrackedStateと同じくuseSelectorのカラムです。このベンチマークではほとんど差がないことが分かるでしょう。他のベンチマーク評価もしましたが、極端な例では差が出るものの、実用に耐えうる範囲とみています。
Proxyを利用している他のプロジェクト
React系では、immerやMobXでProxyが使われています。また、Vue.jsでも使われているとのことです。
Redux Toolkitではimmerを採用していますし、今後Proxyを使ったライブラリは増えてくるかもしれません。
おわりに
reactive-react-reduxのuseTrackedStateについて紹介しました。興味を持ってくださった方は、ぜひ触ってみてください。ちなみに、react-trackedにも同じhookが用意されていますので、非Redux派の方はそちらをどうぞ。
今回は踏み込みませんでしたが、useTrackedStateにも限界がないわけではありません。例えば、次のようなuseSelectorの例については、useTrackedStateで同じ挙動を再現することはできません。
const isYoung = useSelector(state => state.user.age < 10);
最後に、Redux系は、最近、Redux Toolkitをはじめ、ドキュメントの充実化など様々な改善が行われています。これから学び始める人には、良い環境になってきていると思います。