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

これらのモジュールの中身について解説していく。 前回actions.tsを解説した。 今回はreducers.tsstore.tsについて書く。

Reducer

reducers.tsにはReducer、Stateの型、初期Stateを書く。

Reducerは、現在のStateとdispatchされたActionを受け取って次のStateを返す関数で、(previousState, action) => nextStateという形式のもの。 ReducerはStoreに一つだけ登録できて、Storeに任意のActionがdispatchされる度に1回ずつ呼び出される。

State全体を一つのフラットなReducerで処理するのはかなり厳しいので、普通は、ルートReducerでStateをサブツリーに分解して、それぞれのサブツリーをサブReducerに渡して処理させて、その結果をルートReducerでまとめて返す、という分割統治をする。 このような分割統治のために、combineReducers()というヘルパー関数がReduxから提供されている。

re-ducksとReducer

re-ducks流の分割統治では、Stateをduckごとに分割して、duckごとのReducerに処理させる。 例えば、userとarticleというduckがあるとすると、Stateの型は次のようになる。

{
  user: UserState;
  article: ArticleState;
}

で、UserStateを計算するサブReducerと、ArticleStateを計算するサブReducerを書いて、combineReducers()で統合してルートReducerを生成する、という感じ。

reducers.tsに書くのはduckごとのサブReducerとなる。

immer

Reducerを書くときは、副作用が無く参照透過性のある純粋関数でなければいけないことに注意する。 つまり、Ajax通信をしたり、Actionをdispatchしたり、UUIDなどのランダムな値の生成とかはNG。 そういうのはSagaとかに書くべし。 さらに、受け取ったStateとかActionを変更してもいけない。 とくにStateは注意で、ReducerにはStateの計算処理を書くので、Stateに配列とかオブジェクトがあるとうっかり変更してしまうことがあるし、変更しないようにすると配列やオブジェクトのクローンをする記述が多くなって煩雑。

そこを助けてくれるのがimmer。 immerは、Reducerに渡された現在のStateをProxyでラップして、Proxyに対する操作とラップしたStateを基に新しいStateを生成してくれる。 ProxyはStateを透過的にラップしつつStateを変更から守ってくれるので、ユーザはStateを直接いじっているかのようにコードを書けて、且つうっかり変更してしまうという恐れから解放される。

immerは軽量で、提供するAPIはほぼproduce()しかない。 produce()を使うとReducerは以下のように書ける。

src/state/ducks/user/reducers.ts:

import produce from 'immer';

const initialState = {};

export const userReducer = produce((draft, action) => {
  switch (action.type) {
    case 'user/entitiesFetchSucceeded':
      draft.entities = action.payload.user.entities;
      draft.fetching = false;
      break;
    case 'user/entitiesFetchFailed':
      draft.fetching = false;
      break;
  };
}, initialState)

このように、produce()にReducerの処理を実装する関数と初期Stateを渡してやると、元のStateをいじる恐れのないReducerを生成してくれる。

produce()に渡す関数はdraftactionを受け取る。 draftは、生成するReducerが受け取るStateをProxyでラップしたもので、そのStateと同じプロパティを持っていて、直接変更していい。 actoinは生成するReducerが受け取るAction。

produce()に渡す関数を書くときのtipsは以下。

  • draftをreturnする必要はない。してもいいけど。
  • draftのプロパティの操作はいいけど、draft自体への代入(e.g. draft = {})はNG。
  • draftと関係ないオブジェクトを返すのは一応リーガル。ふつうやらないだろうけど。

なお、immerに特にオプションを与えなければ、produce()で作ったReducerは開発ビルドではfreezeしたStateを返すので、Stateを使う側でいじっちゃってる箇所が無いかをチェックアウトしやすい。

因みに、Stateを変更から守るのに昔はImmutable.jsが人気だったけど、Immutable.jsを使うとクラスをたくさん書かなければいけなかったり、Reducerの処理が書き辛くなったり、型が消えたりするので、現代ではRedux公式もimmerを推奨している

Stateの型

Stateの型もreducers.tsに書いておくのがいい。最もStateを触るのがReducerなので、近いところに書いておいて、変更しやすくする意図だ。

ひとつのreducers.tsに書くのは、Stateのうち、duckが管轄する範囲のサブツリーの型だけ。 例えば、

src/state/ducks/user/reducers.ts:

import {
  User,
  NormalizedUsers,
} from './models';

export type UserState = Readonly<{
  dataReady: boolean;
  data: {
    ids: User['id'][];
    entities: NormalizedUsers;
  };
}>;

こんな感じ。

Stateにはドメインモデルのオブジェクトを入れることが多いけど、モデル自体の型(など)はmodels.tsに書いておいてimportする。 (models.tsについては[別の記事で。)

なお、StateオブジェクトはRedux DevToolsで扱えるようにシリアライズ可能に保つというのがRedux公式から強く推奨されているのに注意。 これはつまり、MapSetPromise、クラスのインスタンス、関数オブジェクトをいれてはだめということ。

Reducerの型

immerのところに書いたReducerには型を付けてなかったけど、以下のように付けるといい。

src/state/ducks/user/reducers.ts:

import produce, { Draft } from 'immer';
import {
  usersFetchSucceeded,
  usersFetchFailed,
} from './actions';
import {
  User,
  NormalizedUsers,
} from './models';

export type UserState = Readonly<{
  dataReady: boolean;
  data: {
    ids: User['id'][];
    entities: NormalizedUsers;
  };
}>;

const initialState: UserState = {
  dataReady: false,
  data: { ids: [], entities: {} },
};

export const userReducer = produce(
  (
    draft: Draft<UserState>,
    action:
      | ReturnType<typeof usersFetchSucceeded>
      | ReturnType<typeof usersFetchFailed>,
  ) => {
  switch (action.type) {
    case 'user/entitiesFetchSucceeded':
      draft.entities = action.payload.user.entities;
      draft.fetching = false;
      break;
    case 'user/entitiesFetchFailed':
      draft.fetching = false;
      break;
    default:
      const _: never = action;
  };
}, initialState)

ポイントは3つ。

  • draftの型は、immerが提供するDraftという型に、Stateの型をパラメータとして渡すと作れる。
  • actionの型は、Reducerが扱うべきAction一通りのAction Creatorをactoins.tsからimportして、それらにReturnTypeを適用してActionの型を抽出し、Union型でまとめる形で付ける。

    Actionの型は文字列リテラル型なので、これでcase文に書くtypeに補完と型チェックが利く。

  • default節でactionnever型に代入することで、actionのUnion型に入れたActionがすべてcase文で受けられていることを保証できる。つまり、caseが足らないと、default節に入るactionの可能性が残り、never型に代入できないのでエラーになる。

actionの型をReducerが扱うものに限定はしているけど、Reducerは実際にはすべてのActionのdispatchに対して呼び出されるということに注意。 default節でconsole.log(action)しておくと、Actionがじゃんじゃん表示されることが確認できる。

Stateの型とReducerをduckからexport

reducers.tsがあるディレクトリに置いたindex.tsでStateの型とReducerを再exportしておくと捗る。

src/state/ducks/user/index.ts:

export { UserState, userReducer } from './reducers';

StateとReducerの統合

各duckからexportしたStateの型とReducerは、ducksディレクトリ直下のindex.tsでまとめる。

src/state/ducks/index.ts:

import { UserState, userReducer as user } from './user';
import { ArticleState, articleReducer as article } from './article';

export type StoreState = Readonly<{
  user: UserState;
  article: ArticleState;
}>;

export const reducers = {
  user,
  article,
};

このファイルはduckが増えたときだけ修正が必要。 ここでexportしたreducersstore.tscombineReducers()に入れて統合し、Storeに登録する。

src/state/store.ts:

import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { reducers } from './ducks';

const rootReducer = combineReducers(reducers);

export default function configureStore() {
  const middlewares = [];
  const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
  const store = createStore(rootReducer, composeEnhancers(applyMiddleware(...middlewares)));
  return store;
}

このファイルは一回書いたら修正は不要。

middlewaresはredux-sagaを入れるんだけど、それについては別の記事で書く。

因みにこのconfigureStore()は、ソースのルートのindex.tsxで使う。

src/index.tsx:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from '~/state/store';


const root = document.getElementById('root');
const store = configureStore();

if (root) {
  ReactDOM.render(
    <Provider store={store}>
      <AppRoutes />
    </Provider>,
    root,
  );
}