【Redux】ReselectのcreateSelectorでSelectorをメモ化する

JavaScript

今回利用するサンプルアプリケーションについて

Selectorのメモ化を検証するにあたり、今回はTodo管理機能とカウンタ機能を組み合わせたアプリケーションを利用します。

アプリケーションの仕様は以下の通りです。

  • Stateにはtodo(Todoの内容)、visibilityFilter(todoをフィリタリングする識別子)、count(カウンターの値)が保存されている
  • 「+Count」「-Count」をクリックするとcountを増減させるActiongaが発行される
  • 「all」「completed」「incomplete」をクリックするとvisibilityFilterを変更するActionが発行される
  • 「Add Todo」をクリックするとTodoを追加するActiongaが発行される

アプリケーションに保存されているStateの具体例は以下の通りです。

{
  todos: {
    allIds: [
      1,
      2
    ],
    byId: {
      '1': {
        id: 1,
        content: 'go somewhere',
        completed: true
      },
      '2': {
        id: 2,
        content: 'eat something',
        completed: false
      }
    }
  },
  visibilityFilter: 'completed',
  count: 4
}

Selectorについて

Selectorとは、ReduxのStore内のStateから特定のデータを取得する関数のことをいいます。
Selectorを作成することにより、コンポーネントはSelectorを呼ぶだけでStateから必要なデータが抽出できます。
SelectorはあくまでStateからデータを抽出するための関数にすぎないため、Selectorの実装に別途ライブラリをインストールする必要はありません。

Selectorの利用方法

コンポーネントでSelectorを利用する方法は以下の2つがあります。

Selectorの呼び出し方法
  • useSelectorを利用する
  • connectの第一引数(mapStateToProps)を利用する

具体的なサンプルコードを紹介します。
ここでは例として、Stateに保存されているvisibilityFilterを取得するSelectorをコンポーネントで利用するケースについて考えてみます。

useSelectorを利用した実装は以下の通りです。

import { FC } from "react";
import { AppState } from "../store/index";
import { VisibilityFilterTypes } from "../types/state/visibilityFilter";
import { useSelector } from "react-redux";

const VisibilityFilters: FC = () => {
  const activeFilter: VisibilityFilterTypes = useSelector(
    (state: AppState) => state.visibilityFilter
  );
  return (
    <>
      (略)
    </>
  );
};

export default VisibilityFilters;

connectを利用した実装は以下の通りです。

import { FC } from "react";
import { connect } from "react-redux";
import { AppState } from "../store/index";
import { VisibilityFilterTypes } from "../types/state/visibilityFilter";

type VisibilityFiltersProps = {
  activeFilter: VisibilityFilterTypes;
}

const VisibilityFilters: FC<VisibilityFiltersProps> = ({ activeFilter }) => {
  return (
    <>
      (略)
    </>
  );
};

const mapStateToProps = (state: AppState) => {
  return { activeFilter: state.visibilityFilter };
};

export default connect(mapStateToProps)(VisibilityFilters);

Selectorの特性について

Selectorは任意のアクションが発行(dispatch)されるたびに毎回実行されるという特性があります。これはuseSelectorを利用している場合もconnectを利用している場合も起こります。

上記の特性について、冒頭で紹介したアプリケーションを利用して確認してみます。
以下は、Stateに保存されているtodoの中から画面上に表示するtodoを抽出するSelectorです。

src/selectors/todo.ts

import { AppState } from "../store/index";
import { VisibilityFilterTypes } from "../types/state/visibilityFilter";
import { VISIBILITY_FILTERS } from "../types/constants/visibilityFilterType";
import { TodoState, TodoItem } from "../types/state/todos";

const todos = (state: AppState): TodoState => state.todos;

const selectTodoIds = (state: AppState): number[] => todos(state).allIds;

const selectTodosById = (state: AppState) => todos(state).byId;

const selectTodos = (state: AppState): TodoItem[] =>
  selectTodoIds(state).map((id) => selectTodosById(state)[id]);

export const selectTodosByVisibilityFilter = (
  state: AppState,
  visibilityFilter: VisibilityFilterTypes
): TodoItem[] => {

  // Selectorが実行されたことを確認するためのデバッグログ
  console.log("Selector is called");

  const allTodos = selectTodos(state);
  switch (visibilityFilter) {
    case VISIBILITY_FILTERS.COMPLETED:
      return allTodos.filter((todo) => todo.completed);
    case VISIBILITY_FILTERS.INCOMPLETE:
      return allTodos.filter((todo) => !todo.completed);
    case VISIBILITY_FILTERS.ALL:
    default:
      return allTodos;
  }
};

Selectorの呼び出し側の実装は以下の通りです。

src/components/TodoList.tsx

import { FC } from "react";
import { AppState } from "../store/index";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { TodoItem } from "../types/state/todos";
import { useSelector } from "react-redux";

const TodoList: FC = () => {

  const todos: TodoItem[] = useSelector((state: AppState) => {
    const { visibilityFilter } = state;
    return selectTodosByVisibilityFilter(state, visibilityFilter);
  });

  return (
    <>
      (略)
    </>
  );
};

export default TodoList;

上記のSelectorはcountを利用しないため、countに関するActionには依存していません。
しかし、カウントボタンをクリックした時もログをみてわかるようにSelectorが実行されています。

Reselectについて

Reselectはメモ化されたSelectorを作成するためのライブラリです。
Reselectが提供するcreateSelectorというAPIを利用することでSelectorがメモ化できます。

Redux ToolkitにはReselectがデフォルトでインストールされています。
ReselectはReduxアプリケーションで利用されることが多いですが、Redux以外のJavaScriptアプリケーションでも利用可能です。

先ほど紹介した「Selectorが毎回実行されてしまう」という問題点は、ReselectのcreateSelectorを利用してSelectorをメモ化することで解決できます。

createSelectorの概要と基本構文

createSelectorの定義はcreateSelector(...inputSelectors | [inputSelectors], resultFunc, selectorOptions?)です。

inputSelectorsはStore内の特定のStateを抽出するSelectorです。

resultFuncinputSelectorsによって取得されたStateを引数として受け取り、データ加工などの計算処理を行う関数です。
なお、Redux『createSelector Overview』ではresultFuncのことをoutput selectorという名称で紹介しています。

createSelectorはinputSelectorsの戻り値、つまりresultFuncの処理に必要なStateの値が厳密等価(===)において等しい場合、前回の計算結果を返します。

複数のStateを引数に利用してresultFuncの計算をする場合、つまりinputSelectorsを複数定義する場合は、カンマ区切りもしくは配列でSelectorを並べます。

参考: よくあるcreateSelectorの間違った利用方法

inputSelectorsはStore内の特定のStateを抽出するSelectorですので、stateをそのまま返す(state => state)関数は不適切です。

resultFuncinputSelectorsの戻り値を利用して変換処理を行う部分ですので、以下のようにinputSelectorsの結果をそのまま返すのは不適切です。

// inputSelectorの結果をそのまま返してるだけの無意味なcreateSelector
const brokenSelector = createSelector(
  state => state.todos,
  todos => todos
)

createSelectorのサンプルコード

先ほど紹介した「Stateに保存されているtodoの中から画面上に表示するtodoを抽出するSelector」を例に、createSelectorのサンプルコードを紹介します。

再掲: createSelectorの導入前のSelector

src/selectors/todo.ts

import { AppState } from "../store/index";
import { VisibilityFilterTypes } from "../types/state/visibilityFilter";
import { VISIBILITY_FILTERS } from "../types/constants/visibilityFilterType";
import { TodoState, TodoItem } from "../types/state/todos";

const todos = (state: AppState): TodoState => state.todos;

const selectTodoIds = (state: AppState): number[] => todos(state).allIds;

const selectTodosById = (state: AppState) => todos(state).byId;

const selectTodos = (state: AppState): TodoItem[] =>
  selectTodoIds(state).map((id) => selectTodosById(state)[id]);

export const selectTodosByVisibilityFilter = (
  state: AppState,
  visibilityFilter: VisibilityFilterTypes
): TodoItem[] => {

  // Selectorが実行されたことを確認するためのデバッグログ
  console.log("Selector is called");

  const allTodos = selectTodos(state);
  switch (visibilityFilter) {
    case VISIBILITY_FILTERS.COMPLETED:
      return allTodos.filter((todo) => todo.completed);
    case VISIBILITY_FILTERS.INCOMPLETE:
      return allTodos.filter((todo) => !todo.completed);
    case VISIBILITY_FILTERS.ALL:
    default:
      return allTodos;
  }
};

Selectorの呼び出し側の実装は以下の通りです。

src/components/TodoList.tsx

import { FC } from "react";
import { AppState } from "../store/index";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { TodoItem } from "../types/state/todos";
import { useSelector } from "react-redux";

const TodoList: FC = () => {

  const todos: TodoItem[] = useSelector((state: AppState) => {
    const { visibilityFilter } = state;
    return selectTodosByVisibilityFilter(state, visibilityFilter);
  });

  return (
    <>
      (略)
    </>
  );
};

export default TodoList;

createSelectorの導入後のSelector

src/selectors/todo.ts

import { AppState } from "../store/index";
import { VISIBILITY_FILTERS } from "../types/constants/visibilityFilterType";
import { TodoState } from "../types/state/todos";
import { createSelector } from "@reduxjs/toolkit";
import { selectVisibilityFilter } from "./visibilityFilter";

const todos = (state: AppState): TodoState => state.todos;

const selectTodoIds = createSelector([todos], (todos) => todos.allIds);

const selectTodosById = createSelector([todos], (todos) => todos.byId);

const selectTodos = createSelector(
  [selectTodoIds, selectTodosById],
  (todoIds, todosById) => todoIds.map((id) => todosById[id])
);

export const selectTodosByVisibilityFilter = createSelector(
  [selectTodos, selectVisibilityFilter],
  (todos, visibilityFilter) => {

    // Selectorが実行されたことを確認するためのデバッグログ
    console.log("Selector is called");

    switch (visibilityFilter) {
      case VISIBILITY_FILTERS.COMPLETED:
        return todos.filter((todo) => todo.completed);
      case VISIBILITY_FILTERS.INCOMPLETE:
        return todos.filter((todo) => !todo.completed);
      case VISIBILITY_FILTERS.ALL:
      default:
        return todos;
    }
  }
);

src/selectors/visibilityFilter.ts

import { AppState } from "../store/index";
import { VisibilityFilterTypes } from "../types/state/visibilityFilter";

export const selectVisibilityFilter = (state: AppState): VisibilityFilterTypes =>
  state.visibilityFilter;

Selectorの呼び出し側の実装は以下の通りです。

src/components/TodoList.tsx

import { FC } from "react";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { TodoItem } from "../types/state/todos";
import { useSelector } from "react-redux";

const TodoList: FC = () => {

  const todos: TodoItem[] = useSelector(selectTodosByVisibilityFilter);

  return (
    <>
      (略)
    </>
  );
};

export default TodoList;

createSelectorを導入したことで、ログからわかる通りアプリケーションのカウントボタンをクリックした時にはtodoに関するSelector(getTodosByVisibilityFilter)は実行されなくなります。

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考資料