2018年後半にスクラッチから作ったReactとReduxのプロジェクトテンプレートを2020年版として色々アップデートしているなかで、re-ducksパターンに則ってステート管理のモジュール構成を整理しなおしたり、ステート管理に使うライブラリを見直したりした。

この記事では、前回に続いて、React-ReduxRedux Sagaimmernormalizrreselectを使ったre-ducksパターンの実践について書く。

言語はTypeScript

モジュール構成

次節以降の解説の前提として、React・Redux・React-Redux・redux-sagaのコンポーネントアーキテクチャ図とモジュール構成を再掲しておく。

アーキテクチャ図はこれ:

react-redux-saga

モジュールはviewsとstateに分かれていて、viewsの下はReactコンポーネントがAtomicデザイン風に整理されていて、stateの下はReduxステート管理モジュールがre-ducksパターンで整理されている。

つまり以下のような感じ。

  • src/
    • index.tsx
    • views/
      • AppRoutes.tsx
      • atoms/
      • molecules/
      • organisms/
        • DataTable.tsx
      • ecosystems/
        • user/
          • UserDataTable.tsx
      • natures/
        • user/
          • UserListView.tsx
        • article/
          • ArticleListView.tsx
    • state/
      • store.ts
      • ducks/
        • index.ts
        • user/
          • index.ts
          • actions.ts
          • apis.ts
          • reducers.ts
          • models.ts
          • sagas.ts
          • selectors.ts
          • watcherSagas.ts

これらのモジュールの中身について解説していく。 前回models.tsを解説した。 今回はselectors.tsについてと、ReduxとViewとの接続について書く。

Selector

上記のアーキテクチャ図には見えていないけど、get Stateの線のあたりではSelectorというコンポーネントが動く。 Selectorは、ReduxのStateから必要なデータを抽出し、Viewが扱いやすい形に加工する処理をする関数で、Viewに対してStateを抽象化する層の働きをする。 サーバサイドでいうところのDAOのようなもの。

Selectorにデータ加工のロジックを詰めることで、View側のコンポーネント間でそのロジックを再利用することができる。 また、テストしやすい状態管理側にロジックを書くことになるので、テストしにくいView側をシンプルに保つことができる。


selectors.tsには、対応するduckが扱うStateについてのSelectorを書く。

Selectorは、ReduxのState全体を受け取って抽出した値を返す純粋関数として書く。 つまり、参照透過且つ副作用フリーである必要がある。 まあ普通に書けばそうなるだろうけど。

因みに、データ加工はSelectorの責務なので、前回解説したモデルの非正規化はSelectorの役目。

src/state/ducks/article/selector.ts:

import { StoreState } from '~/state/ducks';
import { denormalizeArticles, articleNormalizrSchemaKey } from './models';
import { userNormalizrSchemaKey } from '~/state/ducks/user/models';

export const isArticleDataReady = ({ article }: StoreState) => article.dataReady;

export const getArticles = ({ article, user }: StoreState) =>
  denormalizeArticles({
    result: article.data.ids,
    entities: {
      [articleNormalizrSchemaKey]: article.data.entities,
      [userNormalizrSchemaKey]: user.data.entities,
    },
  });

Selectorを、View側の都合に引きずられて、Viewが必要なデータを全部まとめて取得して単一オブジェクトにつめて返すような形で書くのはよくない。 むしろ細かい単位で書くと再利用性やテスタビリティが高まっていい。 View側は、細かいSelectorを組み合わせて必要なデータをそろえることになる。 これはRedux公式も推奨しているプラクティス

React-Redux

前節で解説したSelectorは、ViewコンポーネントでReact-ReduxのuseSelector()というHookに渡して使う。 ReduxのStateを意識していいのはContainer Componentなので、useSelector()を使うのはEcosystemかNatureのコンポーネントということになる。

src/views/natures/ArticleListView:

import React, { FunctionComponent, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ArticleList from '~/views/organisms/ArticleList';
import { isArticleDataReady, getArticles } from '~/state/ducks/article/selectors';
import { articlesBeingFetched } from '~/state/ducks/article/actions';
import { useFetch } from '~/views/hooks';

const ArticleListView: FunctionComponent = () => {
  const dataReady = useSelector(isArticleDataReady);
  const articles = useSelector(getArticles);
  const fetching = useFetch(dataReady, articlesBeingFetched());
  const dispatch = useDispatch();
  const onReloadButtonClicked = useCallback(() => {
    dispatch(articlesBeingFetched());
  }, []);

  return (
    <ArticleList
      fetching={fetching}
      articles={articles}
      onReloadButtonClicked={onReloadButtonClicked}
    />
  );
};

export default React.memo(ArticleListView);

こんな感じで、React-ReduxのuseSelector()にSelectorを渡してやるとそのSelectorを呼んでデータを取得してくれる。 詳しく言うと、Selectorは以下のタイミングで実行される。

  • useSelector()したコンポーネントのrender時。要はuseSelector()が実行されるときはSelectorも実行される。
    • ただし、useSelector()に渡されたSelector関数オブジェクトの参照が前回render時と同じ場合は、Selectorは実行されず、useSelector()はキャッシュから前回の結果を返す。
  • StoreにActionがdispatchされてStateが変わったとき。
    • Selectorで触るデータに関わらず、Stateのどの部分でもちょっとでも変われば、アプリケーション内のすべてのSelectorが呼ばれる。
    • Selectorが返した値が前回実行時と異なる場合、そのSelectorでuseSelector()したコンポーネントが再renderされる。
      • Selectorが返す値の比較はデフォルトでは===による参照比較だけど、useSelector()の第二引数で比較関数を渡すこともできる。
    • ActionがdispatchされてもStateが変わらない場合はSelectorは呼ばれない。


React-Reduxにはもう一つ重要なHookがある。 上の例でも使っているuseDispatch()だ。 これでStoreのdispatchメソッドを取得できるので、それを使ってActionをdispatchするイベントハンドラを作ってPresentational Component(上の例ではArticleList)に渡してやるのもContainer Componentの役割。

なお、Selectorを通さずにStateからデータを取得するのはNG。 React-ReduxのuseStore()を使えばView側から直接Stateに触れるけど、それをやりだすとカオスになっていくので。

reselect

前節でuseSelector()に渡したSelectorが実行されるタイミングを解説したんだけど、そのなかに「Selectorで触るデータに関わらず、Stateのどの部分でもちょっとでも変われば、アプリケーション内のすべてのSelectorが呼ばれる。」というのがあった。 これがアプリの性能に響くことは想像に難くない。 Selector関数に重い処理を書いてしまうと大きな性能問題になってしまうことがある。

とはいえ、軽いSelectorばかりでアプリを構築できるとは限らない。 例えば上記のisArticleDataReady()は激軽なので気にする必要はないけど、getArticles()はdenormalizeしてるのでやや重い。 Stateが大きくなって、変更される箇所が多くなるにつれ、getArticles()が呼ばれる回数が増えて性能に影響を与えてくることが考えられる。

こうした問題に対処するためのライブラリがreselect。 reselectのAPIはほぼcreateSelector()だけで、これは一言で言えばSelectorをメモ化してくれる関数。

createSelector()に任意の数の入力セレクタとひとつの結果関数を渡すと、メモ化したSelectorを返してくれる。 入力セレクタはState全体のオブジェクトを受け取り、結果関数の引数を返す関数。 結果関数は、Selectorの戻り値を作って返す関数。 メモ化したSelectorが呼ばれたとき、すべての入力セレクタが返す値が前回と同じ場合、結果関数は実行されず、結果関数の前回の戻り値がSelectorの戻り値になる。

getArticles()createSelector()でメモ化すると以下のようになる。

src/state/ducks/article/selector.ts:

+import { createSelector } from 'reselect';
 import { StoreState } from '~/state/ducks';
 import { denormalizeArticles, articleNormalizrSchemaKey } from './models';
 import { userNormalizrSchemaKey } from '~/state/ducks/user/models';

 export const isArticleDataReady = ({ article }: StoreState) => article.dataReady;

-export const getArticles = ({ article, user }: StoreState) =>
-   denormalizeArticles({
-     result: article.data.ids,
-     entities: {
-       [articleNormalizrSchemaKey]: article.data.entities,
-       [userNormalizrSchemaKey]: user.data.entities,
-     },
-   });
+export const getArticles = createSelector(
+  ({ article }: StoreState) => article.data,
+  ({ user }: StoreState) => user.data,
+  (articleData, userData) =>
+    denormalizeKiyoshies({
+      result: articleData.ids,
+      entities: {
+        [kiyoshiNormalizrSchemaKey]: articleData.entities,
+        [userNormalizrSchemaKey]: userData.entities,
+      },
+    }),
);


これでre-ducksのすべてのモジュールについて解説できた。