reselectでcreateSelectorに引数を持つSelectorを渡してはいけない
タイトルが長い
reselectはReduxにおけるSelectorを合成し,適切にメモ化することで無駄な再レンダーを抑制することができる便利なライブラリだが,こんな落とし穴もありそうという話.
TL;DR
メモ化が機能しなくなるので,reselectに引数を持つ(state以外の引数を受け取る)Selectorを渡してはいけない.引数を持つSelectorを使いたくなった場合
1. createSelectorの返すSelectorをメモ化する
2. 複雑なSelectorを作るのを諦め,custom hookでuseMemoを用いてメモ化する
のどちらかで解決することができる
問題
reselectのソース(型付けはオーバーロードの数が多く複雑だが実装は意外と単純)を読むと,デフォルトでは直前の値だけをキャッシュするメモ化が行われていることがわかる.
この方法はシンプルかつメモリ消費が少なく,比較関数をカスタムすることで簡単に挙動を拡張できるので,React周辺のライブラリでは広く使われている気がする.(useMemo と言えばReactを書いている人は理解できると思う)
このメモ化の方法は、ちょうどRedux Stateのように引数が時間的に変化してそれまでの値に戻ることがないと仮定できる場合はうまく動く。
しかし、そうではない場合に機能しなくなる.例を挙げてみる.
code:example.ts
const selectValue = createSelector(
(state, id: string) => ...,
(state) => ...,
// 新しい配列を作って返すので,メモ化されなければ再描画を誘発してしまう
(a, b) => a, b as const
); // (state: State, id: string) => ... みたいな型の関数になる
const Hoge: React.FC<{ id: string }> = ({ id }) => {
const a, b = useSelector(state => selectValue(state, id));
return ... // JSXを組み立てる
};
const HogeList = () => (
<>
<Hoge id="0"/>
<Hoge id="1"/>
</>
);
最初のレンダリング(このときのStateをstate0とする)ではselectValue(state0, '0')とselectValue(state0, '1')が呼ばれ,(多分)後に呼ばれるselectValue(state0, '1')が保存される.
その後Stateが変化し,state1になったとする.このとき,selectValueの依存になっているSelectorは同じidに対して同じ値を返すことにする.つまり理想的にはHogeは再レンダーされない.しかし次のようにして再レンダーされてしまう実行パスが存在する。
1. selectValue(state1, '0')は新しいオブジェクトを返す.なぜならselectValue(state0, '1')のみがキャッシュされているから
2. selectValue(state1, '1')は新しいオブジェクトを返す.なぜならselectValue(state1, '0')のみがキャッシュされているから
結果としてreselectが行ったメモ化の効果は全く発揮されなかったことになる.あ〜あ
解決策
端的には「createSelectorにstate以外を引数に持つ関数を渡すな」なのだけど,「〜するな」と言うだけなのは生産性がない気がするのでいくつか解決策を出してみる.
1. createSelectorの返すSelectorをメモ化する
安直だけど問題の解決にはなる.const selectValue = memoize((id: string) => createSelector(...))みたいな感じ.
再レンダーを極限まで抑えたい,もしくはreselectでやってる処理が重い場合はこっちを選択することになると思う.
memoizeのkeyの管理や,キャッシュが肥大化しないように管理するのが若干難しい気がする.
2. 複雑なSelectorを作るのを諦め,custom hookでuseMemoを用いてメモ化する
custom hookの中で複数回useSelectorし,必要ならば結果をuseMemoの中で合成してメモ化する.
メモリ消費が少なく考えることも少ないが,1.より若干再レンダーされやすいケースがあると思う.
変種として,createSelectorで非正規化などを行っている場合,それをせずにidだけを伝播させて各コンポーネントでuseSelectorする方法がある.設計に立ち入ることになるので常に推奨されるわけでもないと思うが,再レンダーを抑制するという立場からすると強力そう.Redux Style Guideで言及されている https://redux.js.org/style-guide/style-guide#connect-more-components-to-read-data-from-the-store
3. memoize関数を自作してcreateSelectorCreatorに渡す
idまで見てメモ化する対象のモデル専用のメモ化関数を作ってメモ化する
#フロントエンド