はじめに
Reduxを使っていると、こんな経験はないでしょうか。ストアの中の配列から「完了済みのTodoだけ抽出する」「合計金額を計算する」といった派生データをmapStateToPropsやコンポーネント内で毎回計算していたら、関係ない状態が変わるたびに再計算が走り、そのたびに新しい配列やオブジェクトが生成されて、子コンポーネントまで無駄に再レンダリングされてしまう。
原因は単純で、state.todos.filter(...)のような処理は呼び出すたびに新しい参照を返すからです。ReactやReduxは参照が変わったかどうかで再描画を判断するため、中身が同じでも再レンダリングが止まりません。
Reselectは、この「派生データの計算」をメモ化することで解決するセレクタライブラリです。入力が変わっていなければ、前回と同じ参照をそのまま返してくれます。
とはいえ、説明を読むより実際に動かした方が早いと思います。createSelectorで作ったセレクタが、無関係な状態変化では再計算されない様子を確認できるサンプルを用意しました。先に挙動を見たい方はこちらからどうぞ。
Reselectとは
Reselectは、Redux公式チーム(reduxjs)が開発・メンテナンスしている「Reduxのためのセレクタライブラリ」です。2015年公開と歴史が長く、GitHubで19,000スター以上を集める定番ライブラリで、現在も活発に更新が続いています。Redux Toolkitにも組み込まれており、実質的にReduxのセレクタを書くときの標準的な選択肢になっています。
やっていることはシンプルで、「入力セレクタの結果が前回と同じなら、結果関数を再実行せずキャッシュを返す」というメモ化だけです。Redux以外の状態管理(Zustandやただのオブジェクトツリー)でも使えますが、本来はReduxのstateから派生データを取り出す用途に最適化されています。
主な特徴
- メモ化による再計算の抑制 - 入力値(引数)が前回と同じ参照であれば、結果関数を再実行せずキャッシュされた結果を返す
- セレクタの合成 - 複数のセレクタを組み合わせて、新しいセレクタを作れる。ロジックの再利用がしやすい
- 軽量 - 依存はほぼなく、コアの実装はごくわずか。Redux Toolkitのバンドルにもそのまま含まれる
- TypeScriptとの高い親和性 - v5では型推論の上限が大幅に改善され、ネストしたセレクタでも型エラーが出にくくなった
インストール
npm install reselect
yarn add reselect
pnpm add reselect
Redux Toolkitを使っている場合は、@reduxjs/toolkitからcreateSelectorが再エクスポートされているため、個別インストールは不要です。
Reselectのサンプルを動かす
createSelectorは、複数の「入力セレクタ」と1つの「結果関数」を受け取り、メモ化されたセレクタを返す関数です。以下のサンプルでは、Todoリストのstateから「完了済みの件数」を計算するセレクタを作り、無関係なプロパティ(filterTextなど)を更新してもセレクタが再計算されないことを確認できます。
要点だけを抜き出すと、次のような形になります。
import { createSelector } from 'reselect'
const selectTodos = (state) => state.todos
const selectCompletedCount = createSelector(
[selectTodos],
(todos) => {
console.log('再計算しました')
return todos.filter((t) => t.done).length
}
)
selectCompletedCount(state) // 初回は計算する
selectCompletedCount(state) // todosが変わっていなければキャッシュを返す
実際に動かせるものが下です。ボタンで「無関係な状態」と「todos自体」をそれぞれ更新できるので、どちらのときに再計算が起きるかをコンソールで見比べてみてください。
「無関係なfilterTextだけ更新」を何度押しても、コンソールに「再計算しました」は出ません。selectTodosが返すstate.todosの参照が変わっていないので、createSelectorがキャッシュした結果をそのまま返しているためです。一方「todosに追加」を押すと、state.todos自体が新しい配列に置き換わるため、そのたびに再計算のログが出ます。これがReselectの最も基本的な価値です。
基本的な使い方
Reduxのstateから派生データを取り出す典型的なパターンは次のとおりです。
import { createSelector } from 'reselect'
// 入力セレクタ: stateから必要な部分だけを取り出す
const selectTodos = (state) => state.todos
const selectVisibilityFilter = (state) => state.visibilityFilter
// 結果関数: 入力セレクタの返り値を受け取って派生データを計算する
const selectVisibleTodos = createSelector(
[selectTodos, selectVisibilityFilter],
(todos, filter) => {
switch (filter) {
case 'SHOW_COMPLETED':
return todos.filter((t) => t.done)
case 'SHOW_ACTIVE':
return todos.filter((t) => !t.done)
default:
return todos
}
}
)
selectTodosとselectVisibilityFilterのどちらの返り値も前回と同じであれば、selectVisibleTodosは結果関数を実行せず、キャッシュされた配列をそのまま返します。React ReduxのuseSelectorと組み合わせれば、無駄な再レンダリングを抑えられます。
実践的なユースケース
パラメータ付きセレクタでコンポーネントごとに絞り込む
「特定のIDのTodoだけ欲しい」のように、コンポーネントのpropsなど外部の値を条件に使いたい場面はよくあります。ただし、Reselectの標準のcreateSelectorはデフォルトでは直近1回分しかキャッシュしないため、異なる引数で複数のコンポーネントから呼ばれると、キャッシュが効かず毎回再計算されてしまいます。この問題は、コンポーネントごとにセレクタのインスタンスを分ける(useMemoなどで保持する)ことで回避します。
セレクトボックスをhighとlowで切り替えるたびに、コンソールに再計算のログが出ることに気づくはずです。これはselectTodosByPriorityが第2引数のpriorityも入力として扱っており、直近のキャッシュが1件しか保持されないためです。同じhighを連続で選んでも再計算が起きない一方、lowに切り替えた瞬間だけ再計算されるのを確認してみてください。
createSelectorCreatorでメモ化ロジックをカスタマイズする
デフォルトのcreateSelectorは「引数が参照として同じか」で判定しますが、値そのものを比較したい場合や、キャッシュ件数を増やしたい場合はcreateSelectorCreatorでメモ化関数を差し替えられます。Reselect v5ではlruMemoize(複数件キャッシュ)やweakMapMemoize(引数の組み合わせをツリー状にキャッシュ)が標準で用意されています。
通常のcreateSelectorなら、オブジェクトの参照が変わっただけで再計算されてしまいます。しかしこのサンプルのselectFilterSummaryはequalityCheckに中身を比較する関数を渡しているため、「中身が同じ新オブジェクトを渡す」を押しても再計算されず、「中身を変える」を押したときだけログが出ます。APIから毎回新しいオブジェクトが返ってくるが中身は変わらない、といったケースで役立つパターンです。
まとめ
Reselectは、Reduxのstateから派生データを取り出す処理を、参照の比較だけで安全にメモ化してくれるライブラリです。createSelectorで基本のメモ化を、createSelectorにパラメータを渡すことでコンポーネントごとの絞り込みを、createSelectorCreatorでキャッシュ件数や比較方法のカスタマイズを、それぞれ実現できます。
「Reduxのstateから計算したデータを使うたびに再レンダリングが多い気がする」と感じたら、まずは既存のmapStateToPropsやセレクタ関数をcreateSelectorで包んでみるところから試してみてください。
