😸

Reduxのreselectを改めて理解する

2022/06/07に公開

初めに

こんにちは、エンジニアの籏野です。

フォルシアのアプリ開発ではアプリの状態管理にReduxを用いることが多いです。
Reduxから状態を取得するときに「reselect」という言葉が出てきますが、どのような点が嬉しいのかがいまいちわかっていなかったので調べました。

前準備

さくっとReduxを利用したアプリケーションを用意しましょう。

$ npx create-react-app redux-selector-experience --template redux-typescript
$ cd redux-selector-experience
$ npm run start

デフォルトではhttp://localhost:3000/ にアクセスすれば画面を参照できました。

今回作ったものはgithubにあげています。

確認

今回の確認のため、以下のようなsliceとcomponentを用意します。

// features/counter/counterSlise.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { RootState } from "../../app/store";

export interface CounterState {
  count_1: {
    val: number;
  };
  count_2: {
    val: number;
  };
}

const initialState: CounterState = {
  count_1: {
    val: 0,
  },
  count_2: {
    val: 0,
  },
};

export const counterSlice = createSlice({
  name: "counter",
  initialState,
  // The `reducers` field lets us define reducers and generate associated actions
  reducers: {
    increment: (state, action: PayloadAction<keyof CounterState>) => {
      state[action.payload].val += 1;
    },
    decrement: (state, action: PayloadAction<keyof CounterState>) => {
      state[action.payload].val -= 1;
    },
  },
});

export const { increment, decrement } = counterSlice.actions;

export const selectCount1 = (state: RootState) => {
  console.log("select count_1");
  return state.counter.count_1.val;
};

export const selectCount2 = (state: RootState) => {
  console.log("select count_2");
  return state.counter.count_2.val;
};

export default counterSlice.reducer;
// features/counter/Counter.tsx

import { useAppSelector, useAppDispatch } from "../../app/hooks";
import {
  decrement,
  increment,
  selectCount1,
  selectCount2,
} from "./counterSlice";
import styles from "./Counter.module.css";

export function Counter() {
  return (
    <div>
      <Counter1 />
      <Counter2 />
    </div>
  );
}

const Counter1 = () => {
  const dispatch = useAppDispatch();
  const count1 = useAppSelector(selectCount1);

  return (
    <div className={styles.row}>
      <span>COUNT1</span>
      <button
        className={styles.button}
        aria-label="Decrement value"
        onClick={() => dispatch(decrement("count_1"))}
      >
        -
      </button>
      <span className={styles.value}>{count1}</span>
      <button
        className={styles.button}
        aria-label="Increment value"
        onClick={() => dispatch(increment("count_1"))}
      >
        +
      </button>
    </div>
  );
};

const Counter2 = () => {
  const dispatch = useAppDispatch();
  const count1 = useAppSelector(selectCount2);

  return (
    <div className={styles.row}>
      <span>COUNT2</span>
      <button
        className={styles.button}
        aria-label="Decrement value"
        onClick={() => dispatch(decrement("count_2"))}
      >
        -
      </button>
      <span className={styles.value}>{count1}</span>
      <button
        className={styles.button}
        aria-label="Increment value"
        onClick={() => dispatch(increment("count_2"))}
      >
        +
      </button>
    </div>
  );
};

selector関数による再計算

出来上がったアプリケーション上で、COUNT1/COUNT2のどちらの値を+/-ボタンで変更した場合でもコンソールに以下の内容が出力されます。

// consoleの出力結果
select count_1
select count_2

ステートに変更があった場合にはすべてのselector関数が再計算され返り値によって、コンポーネントを再レンダリングするかどうかを制御することになります。
Reduxが管理するステートは1つの巨大なオブジェクトになっているそうなので言われてみれば当然なのですが、更新していない値のselectorが再計算されるのは驚きました。

これが問題になってくるのは、selector関数が何かしら重い処理を持っている場合になります。
ステートが変更されるたびにselector関数が再計算され、この重い処理が実行されることになっていしまいます。

以下のようにselectCount2を定義し直すと、COUNT1/COUNT2のどちらを変更しても「"very very heavy function"」が毎回出力されることがわかります。

const veryHeavyFunction = (count: number) => {
  console.log("very very heavy function");
  // 何かとても重い処理
  return count;
};

export const selectCount2 = (state: RootState) => {
  console.log("select count_2");
  return veryHeavyFunction(state.counter.count_2.val);
};
// consoleの出力結果
select count_1
select count_2
very very heavy function

reselectを使う

上記の課題を解決するために利用するのがreselectになります。
reselectを使うことで重い処理部分をメモ化して、必要な時だけ処理を実行させることができるようになります。

import { createSlice, PayloadAction, createSelector } from "@reduxjs/toolkit";
...
export const selectCount2 = createSelector(
  (state: RootState) => state.counter.count_2,
  (count_2) => {
    console.log("select count_2");
    return veryHeavyFunction(count_2.val)
  }
);

Redux-toolkitでreselectを利用するには、createSelectorを使います。
createSelectorの定義は以下のようになっており、inputSelectorsの返り値に変更があった場合にのみresultFuncが実行されます。

createSelector(...inputSelectors | [inputSelectors], resultFunc, selectorOptions?)

先のようにselectCount2書き換えることで、COUNT2に変更があった時のみveryHeavyFunctionの処理が走っていることが以下の出力からもわかります。

// COUNT1を変更した場合
select count_1

// COUNT2を変更した場合
select count_1
select count_2
very very heavy function

まとめ

useSelectorは何気なく利用していましたが、その挙動やパフォーマンス観点での影響については理解できていませんでした。
今回の記事が少しでも皆さんの理解の助けになると嬉しいです。

FORCIA Tech Blog

Discussion