React Redux: 毎回新しい参照を返す selector は createSelector で定義しよう

プロフィール画像

Daisuke Yamamura

2024年09月11日

リンクをコピーXでシェアするfacebookでシェアする

はじめに

はじめまして、メンバーズの山村です。
普段の業務では、Web フロントエンド領域を中心に、アプリケーションの開発作業を行っています。
業務の中でよく利用している、React やその関連ライブラリについて、私自身が遭遇したトラブルと、その解決法や、Tips などをお伝えしていきたいと思います。

今回は、React + React Redux の環境下で発生する、以下の warning を中心に、useSelector、createSelector の機能、役割について説明します。

Selector [selector name] returned a different result when called with the same parameters. This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization

warning が発生するコード

warning が発生するコードを紹介します。
まずは slice の定義から見ていきましょう。

import type { createSlice, type PayloadAction } from "@reduxjs/toolkit";

export interface FruitSliceState {
  fruits: string[];
}

const initialState: FruitSliceState = {
  fruits: [],
}

export const fruitSlice = createSlice({
  name: "fruit",
  initialState,
  reducers: create => ({
    add: create.reducer(
      (state, action: PayloadAction<string>) => {
         state.fruits.push(action.payload)
      },
    ),
  }),
  selectors: {
    selectFruitsWithoutApple: state => state.fruits.filter(fruit => fruit !== "Apple")
  },
})

export const { add } = fruitSlice.actions
export const { selectFruitsWithoutApple } = fruitSlice.selectors

string の配列 fruits を filter し、新しい配列として返す selectFruitsWithoutApple  selector を定義しています。

コンポーネントのコードは以下のようになっています。

import { selectFruitsWithoutApple } from "./features/fruits/fruitSlice";
import { useSelector } from “react-redux”;

export default function App() {
  const fruits = useSelector(selectFruitsWithoutApple);

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

この実装を Web ブラウザ上で実行すると、冒頭でも述べた warning が console 上に発生します。
(process.env.ENVIRONMENT !== "production"  となる環境で実行された場合しか発生しません)

Selector [selector name] returned a different result when called with the same parameters. This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization

原因や解決法は、このメッセージ内でほとんど説明されています。少しずつ紐解いていきましょう。

useSelector について

useSelector は、React Redux から利用できる hooks です。実装者が定義した selector を引数として渡すことで、state 内の値や、値を加工したものを取得することができます。

また、Redux state の更新によって useSelector が返す値が変化したとき、再レンダリングが発生します。
デフォルトでは、selector が返す値について、最新の値と前回の値を === (厳密等価)で比較し、異なる場合に再レンダリングを引き起こします。

上記のコードでは、App.tsx から selectFruitsWithoutApple selector を渡すことで利用しています。

warning メッセージについて

warning メッセージでは、「selector が、same parameter(上の例では、selector の引数である state が同じ状態)のとき、異なる結果を返している」と述べられています。
state が同じ状態であれば、ランダム性のない selectFruitsWithoutApple selector は同じ値を返すはずですが、異なる結果と認識されています。

原因

これは、slice.ts 内で定義された selector が、
filter(fruit => fruit !== "Apple") という処理によって、新しい配列を返しているため発生しています。
filtermap は、与えられた配列を処理して新しい配列を返す関数です。selector が返した配列の内容が、前回の実行時と実質的に同一だったとしても、useSelector が行う === の比較では false となり、新しい値が返却されたとみなされます。その結果、不要な再レンダリングが発生してしまいます。

React Redux v8.1.0Open in new tab から、このような不要な再レンダリングが行われる selector の実行を検出し、warning を表示してくれるようになっています。(process.env.ENVIRONMENT !== "production" 環境下のみ)

解決法

これを解決するための、最も一般的、かつ Redux 公式からも推奨されている方法は、createSelector を利用して selector を置き換える、というものです。

createSelector は、redux ファミリーの reselect ライブラリから提供されている関数で、メモ化(memoized)された新しい selector を作成することができます。

slice のコードを以下のように変更してみましょう。

export const fruitSlice = createSlice({
  name: "fruit",
  initialState,
  reducers: { /*(省略)*/ }
  selectors: {
    selectFruitsWithoutApple: createSelector(
      (state: FruitSliceState) => state.fruits,
      fruits => fruits.filter(fruit => fruit !== "Apple")
    ),
  },
})

createSelector に渡した第一引数 (state: FruitSliceState) => state.fruits は、createSelector における「入力セレクタ(input selector)」として扱われます。入力セレクタが返す値が変化しない限り、前回の計算結果が再利用されるため、メモ化が実現されます。
これで、warning は発生しなくなり、selector が毎回新しい値を返していたことによる不要な再レンダリングも抑制されました!

値の比較について

createSelector の入力セレクタの内部で、返す値が変化したかを検証するのに使われるのは === ではなく、shallowEqual や shallowCompare と呼ばれる「浅い比較」です。値が配列の場合は、新・旧の配列要素同士を比較して値の一致を検証します。

この例の場合、入力セレクタからは毎回新しい配列が返されますが、state.fruits が変化していないことが shallowEqual によって期待通りに検証されるため、メモ化を達成することができます。

しかし、shallowEqual は、値を深い階層まで再帰的に走査する、いわゆる deepEqual のような比較ではありません。以下の例のように、新・旧の値が実質的には一致していても、shallowEqual では一致とみなされない場合もあることに注意が必要です。

// shallowEqual(React Redux) で、
// 一致とみなされる例
const a = [“a”, “b”, “c”];
const b = [“a”, “b”, “c”];
shallowEqual(a, b); // true

// 一致とみなされない例
const a = [[“a”], [“b”], [“c”]];
const b = [[“a”], [“b”], [“c”]];
shallowEqual(a, b); // false

deepEqual で比較する

shallowEqual でなく deepEqual を使ってメモ化を実現したい場合は、自分でカスタムした createSelector を作成、利用することで実現できます。deepEqual の実装の一例として、lodash の isEqual を使う例を以下に示します。

import { createSelectorCreator, lruMemoize } from “reselect”;
import isEqual from “lodash.isequal”; 

// isEqual(deepEqual)を使って値を比較しメモ化する、カスタムされた createSelector
const createDeepEqualSelector = createSelectorCreator(
  lruMemoize,
  isEqual
);

上記のように、createSelectorCreator を使うことで、isEqual を比較関数として利用するようカスタムすることができます。
あとは、通常の createSelector を利用するときと同じように、selector として指定すれば完了です。

export const exampleSlice = createSlice({
  name: "example",
  initialState,
  reducers: { /*(省略)*/ }
  selectors: {
    exampleSelector: createDeepEqualSelector(
      (state: ExampleSliceState) => state.example,
      example => example.filter(/*(省略)*/)
    ),
  },
})

おわりに

React Redux やその周辺ライブラリの実装などに触れることで、リアクティブな表示がどう実現されているかなど、深い理解を得ることができました。
ドキュメントや実装などを追いながら、よりパフォーマンスの優れたアプリケーション開発を目指したいと思います。

参考文献

この記事を書いた人

Daisuke Yamamura
Daisuke Yamamura
2018年に新卒でメンバーズへ入社し、Web フロントエンド領域を中心に開発業務へ従事。Web MIDI API や Web Serial API などの、ややニッチな技術で何か作るのが好き。
ページトップへ戻る