Reduxにおけるreducer分割とcombineReducersについて - Qiita
こちらの記事を読んで、私も(Redux は勉強し始めたばかりだけど)似たような感想を持っていて
最後のコメントに書かれていた reselect というライブラリが気になったので、調べてみました。
取り急ぎ、こちらの公式ドキュメントおよび YouTube にあった動画を見ておおざっぱに理解したつもりでいるので、自分なりに整理してみます。
- (公式ドキュメント) Computing Derived Data | Redux
- (動画) [React/Redux] Logicless Components with Reselect - YouTube
解決したい課題
ドキュメントに書いてあったのと同じく、Redux の basics チュートリアル を例に考えてみる。
このチュートリアルを通して作成したのは、以下のようなフィルタ機能つきの Todo アプリだった。
このアプリにおいて、中央の Todo のリストは
現在登録されているすべての todos
を、現在選択されている visibilityFilter
でフィルタリングしたものだけを表示している。
該当のコードはこんな感じだった。
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
const mapStateToProps = (state) => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter)
}
}
...
const VisibleTodoList = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
export default VisibleTodoList
この時の問題として、getVisibleTodos()
は 関係する state.todos
もしくは state.visibilityFilter
に更新があったかどうかに関わらず state
が更新されるたびに実行されるので、フィルタリング処理の計算コストが高かった場合パフォーマンスに影響が出てしまう。
Redux には「state
はアプリケーション全体で1つのツリーオブジェクトである(Single source of truth)」という原則があるが、
state
のツリーが巨大になったとき、たいてい各コンポーネントで関心のある state
はツリーの中のほんの一部にしかすぎないはずなのに
無関係なツリーの更新によって計算処理が何度も実行されてしまうのは無駄である。
また、動画の方では別の問題点も挙げていた。
(キャプチャは [動画](https://youtu.be/XCQ0ZSr-a2o?t=4m42s) より引用)問題の1つは、内部で保持しているデータ(state
)の構造をコンポーネント側が知っていなきゃいけないということと、
もう1つは、フィルタリング処理などのロジック部分をコンポーネント側に持たせてしまうとそのロジックを別のところで再利用することが難しくなるということだった。
今回の Todo アプリの例では Container Component と Presentational Component に切り分けられているのでそこまであてはまらないかもしれないが
言ってることはわかる気がする。
Selector の導入
この問題を解決するのが reselect というライブラリである。
reselect は Selector という機能を提供する。
ざっくり言うと、Selector は state の中から自分が関心のあるツリー部分だけを抜き出してきて
抜き出してきたパラメータから必要な計算を行う。
先に、上の VisibleTodoList の例を Selector を用いて書き直すと、こうなる。
import { createSelector } from 'reselect';
const visibilityFilterSelector = (state) => state.visibilityFilter;
const todosSelector = (state) => state.todos;
export const visibleTodosSelector = createSelector(
[ visibilityFilterSelector, todosSelector ],
(visibilityFilter, todos) => {
switch (visibilityFilter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed);
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed);
}
}
)
import { connect } from 'react-redux';
import { toggleTodo } from '../actions';
import TodoList from '../components/TodoList';
import { visibleTodosSelector } from '../selectors';
const mapStateToProps = (state) => {
return {
todos: visibleTodosSelector(state)
}
};
container component 側で計算していた処理が、 createSelector()
なる関数にまるっと移行された。
ここで登場人物としては大きく2つある。
input selectors
これは上の例で言う visibilityFilterSelector
や todosSelector
が相当する。
input selector は「state
を引数に受け取り、関心のある部分を返すだけの関数」で、後述する createSelector()
の input になるためこう呼ばれる。
state
から関心のある部分だけを抽出するのが役割なので、計算などは行わない。
createSelector(...inputSelectors | [inputSelectors], resultFunc)
こちらが reselect のメイン。
この関数は先ほどの input selectors を引数に受け取り、input selectors が返す結果を使った計算処理を関数として最後の引数に定義する。
上の例だと
(visibilityFilter, todos) => {
switch (visibilityFilter) {
case 'SHOW_ALL':
return todos;
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed);
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed);
}
}
部分が resultFunc
。
resultFunc
の引数には、createSelector()
に渡した input selectors の戻り値が順番に渡されてくる。
その他の API
https://github.com/reactjs/reselect#api を参照。
今回の例で登場しなかったので調べられてない。
createSelector()
を使うと何がうれしいか
createSelector()
の挙動は
createSelector
determines if the value returned by an input-selector has changed between calls using reference equality (===).
...
Selectors created withcreateSelector
have a cache size of 1. This means they always recalculate when the value of an input-selector changes, as a selector only stores the preceding value of each input-selector.
(https://github.com/reactjs/reselect#createselectorinputselectors--inputselectors-resultfunc)
なので、呼ばれるたびに input selectors の戻り値(つまり state
のうち関係する部分)に更新があったかどうかを ===
で比較し、更新がなかった場合は resultFunc
は再実行せず、キャッシュしておいた直前の結果を利用する。
そのため、コンポーネント側からロジックが分離できただけでなく、state
のツリーのうち、関係のある部分が更新されない限りは getVisibleTodos()
による再計算は発生しない。
ということで、上述した問題が解決できた。ということなのだと理解した。
おまけ:createSelector()
で props
も参照したい
参考:Accessing React Props in Selectors
mapStateToProps()
内で props
も引数に渡してあげれば良い。
const mapStateToProps = (state, props) => {
return {
todos: visibleTodosSelector(state, props)
}
};
こうすると各 input selectors 内で props
が使えるようになる。
const visibilityFilterSelector = (state, props) =>
state.todoLists[props.listId].visibilityFilter;
const todosSelector = (state, props) =>
state.todoLists[props.listId].todos;
おまけ:計算結果のメモ化(memoization)という考え方
公式ドキュメントを読んでいると memoized selector という単語が頻繁に登場する。
memoize(d) あるいは memoization は、日本語だと「メモ化」と呼び、Wikipedia では
メモ化(英: Memoization)とは、プログラムの高速化のための最適化技法の一種であり、サブルーチン呼び出しの結果を後で再利用するために保持し、そのサブルーチン(関数)の呼び出し毎の再計算を防ぐ手法である。
と書かれている。
今回 createSelector()
がやっていることがまさにそれで、一度行った計算結果をメモ化して保存してくれるので、memoized selector という用語を使っているみたい。
input selectors は毎回実行されるので memoized ではなく、計算は行わないと言ったのはそういう理由から。
おわりに
元々感じていたのは「reducer を分割してるのに、ある action に対してすべての reducer が実行されるのはなんでなんだろう? action に対応する reducer だけ反応してくれればいいのに」という疑問だった。
今回の Selector によって、コンポーネント毎に自分が関心のある state だけを監視することができるようになったわけだけども
reducer は相変わらずすべて実行されているので、そこは今後も注意したい。
Redux はまだまだ理解できていないことだらけなので、間違ってるところがあればご指摘いただければ幸いです。