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

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

言語はTypeScript

モジュール構成

モジュールは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

これらのモジュールの中身について解説していく。

理解を助けるため、前回書いたReact、Redux、React-Redux、redux-sagaのコンポーネントアーキテクチャ図を再掲しておく。

react-redux-saga

Action

actions.tsにはAction Creatorを書く。 Action CreatorはReduxのStoreにdispatchするActionを生成して返す関数なので、Action Creator自体のまえにActionについて解説しておく。

ActionはFlux Standard Action (FSA)に従ったものにするべきで、これは以下のプロパティを持つプレーンなオブジェクト。

  • type: Actionの意味を表す文字列。ReducerやSagaはここを見て実行する処理を決定する。
  • payload: Reducerによる状態計算とかSagaによる副作用の実行に必要なデータを渡すために使う任意の型のオブジェクト。
  • error: Actionがエラーを表すものかどうかを示すフラグ。
  • meta: payloadに入れる感じでもないかなというメタ情報を入れる任意の型のオブジェクト。

型で見ると以下の感じ。

{
  type: string;
  payload?: object;
  error?: boolean;
  meta?: object;
}


typeは、

  • FSAであるためにはstringであればいいけど、文字列リテラル型にしておくとエディタの補完が利いたり型チェックが利いてうれしい。
  • domain/eventNameというコンベンションで書くのがいい。つまり、ドメインで名前空間を分けて、キャメルケースで書く。ドメインは大抵はモデル名にしておけばいい。(ただし、そのドメインのReducerやSagaだけがそのActionに反応するというわけでは必ずしもない。)
  • eventNameの部分は命令的より宣言的であるべき。(AffectよりEffect、動詞的より名詞的という意見もあるけど、意図することはだいたい同じな気がする。)特に、setXXXXみたいなsetterにしてしまうのはありがちな間違い。ReduxはView側で何が起こったかと、それによってStateがどう変わるかとを分離することで保守性を高めるライブラリだけど、setterなActionは明らかにそれらがごっちゃになっている。ごっちゃになると、Stateの内部構造がView側のイベントハンドラ(など)にリークして、Reduxのうまみが減る。
  • eventNamexxxButtonClickedみたいな感じにしてViewのイベントと紐づけちゃうのも、ViewとStateとの結合度が不必要に高まるのでよくない。例えば、あるモデルをフェッチするというActionは状態管理の観点ではひとつでいいわけだけど、ViewのイベントベースでActionを考えると、XX画面ロードActionでフェッチして、YYボタンクリックActionでも同じモデルをフェッチして、という感じで、State側が冗長になったり複雑になったりしてしまう。


payloadは、

  • そのデータを(主に)扱うduckの名前空間をトップレベルに作って、データは名前空間の下にいれるといい。一つのActionが複数のduckに処理される場合、複数の名前空間を作ってデータを分ける。こうしておくと、データ構造を経由したduck間の暗黙的依存が無くなってうれしい。


errorは、

  • あまり使い道はない。まず、FSAは、ある操作の成功と失敗を同じtypeのActionとして、payloaderrorで見分けるというのを推奨している。

    例えば成功のActionが

    {
      type: 'todo/entityAdded',
      payload: {
        todo: {
          text: 'Do something.'
        },
      },
    }
    

    こんな感じで、失敗のActionが

    {
      type: 'todo/entityAdded',
      payload: new Error(),
      error: true
    }
    

    というように、同じtypeのもの。

    しかしこれだと、payloadの型がerrorフラグによって変わる、つまりUnionになっちゃうのが扱いにくくて微妙。 それに、これらを受け取るReducerなりSagaなりはerrorをif文で見て処理をわけろということなんだろうけど、だったら最初からtype分けとけばいいんじゃないの? という疑問が絶えない。 なのでtypeは成功と失敗とで変えるとして、errorはあってもなくてもという感じ。 任意のActionについてerrorがtrueならログに吐くみたいな共通処理をしたい場合にはつけといてもいいかも。

  • errortrueのとき(というかActionが失敗をあらわすとき)はpayloadErrorオブジェクトを入れるのが作法。


metaの使い道は不明。なにか統計的な情報を入れてログに吐くとか?


以上まとめると、例えば、Userモデルのフェッチに成功したときのアクションは以下のような感じになる。

{
  type: 'user/entitiesFetchSucceeded',
  payload: {
    user: {
      entities: [
        { 'id': 1, 'name': 'うんこ たれ蔵' },
        { 'id': 2, 'name': '山本 菅助' },
      ],
    },
  },
}


なお、Actionオブジェクトはイミュータブル扱いにしておくべし。 Actionオブジェクトは色んなコンポーネントがみるので、中身をいじるとどこに影響するかが分からないからだ。 ただ、Reduxのミドルウェアがメタ情報を付加したりすることがあるので、freezeはしないほうがいい。

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

Action Creator

前節にも書いたけど、Action CreatorはReduxのStoreにdispatchするActionを生成して返す関数。

Action CreatorはシンプルにActionオブジェクトを作るだけにして、ロジックは何も書かず、副作用も起こさず、参照透過にしておくべし。 複雑なことはReducerやSagaとかに任せておくのがいい。

前節の最後に書いたActionのAction Creatorは以下のような感じ。

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

export const usersFetchSucceeded = (
  users: User[],
): Readonly<{
  type: 'user/entitiesFetchSucceeded';
  payload: { user: { entities: User[] } };
}> => ({
  type: 'user/entitiesFetchSucceeded',
  payload: {
    user: { entities: users },
  },
});

受け取ったUserのリストをpayloadに詰めて返すだけ。

よく、Actionのtypeを文字列定数でexportして他から参照できるようにするのを見るけど、その必要はない。 上記Action Creatorのように、typeは文字列リテラル型にしておけば、エディタの補完やTypeScriptの型チェックが利くのでそれで充分。

上記Action Creatorには、Actionの型を戻り値の型として直接記述しているけど、その型を別途定義してexportしてもいい。 しなくても、TypeScriptのUtilityタイプのReturnTypeを使って、ReturnType<typeof usersFetchSucceeded>のようにAction Creator経由で参照できるので、こちらのほうがすっきりしていていい気がする。


actions.tsの解説だけでいいボリュームになったので、ほかのモジュールについては別記事に書く。