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を解説した。 今回はsagas.tsapis.tswatcherSagas.tsについて書く。

Worker Saga

sagas.tsにはAjaxコールなどの副作用やUUID生成などの不安定な処理を実行するSagaをredux-sagaで書く。

redux-sagaについては以前の記事にも書いたのでさらっとだけ説明すると、Reduxのミドルウェアとして動き、Actionの取得、副作用の実行、Actionのdispatchといった処理をReducerの外で実行できるライブラリ。 これらの処理はSagaと呼ぶジェネレータ関数に書く。

Sagaには大きく次の二種類がある。

  • Watcher Saga: StoreへのActionのdispatchをwatchしてActionを取得するSaga。
  • Worker Saga: Watcher Sagaからフォークして実際に副作用とかの処理を実行するSaga。

sagas.tsに書くのはWorker Sagaの方で、例えばREST APIをコールするSagaは以下のような感じ。

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

import { call, put, SagaReturnType } from 'redux-saga/effects';
import {
  usersFetchSucceeded,
  usersFetchFailed,
} from './actions';
import * as apis from './apis';

export function* fetchUsers() {
  try {
    const users: SagaReturnType<typeof apis.getUsers> = yield call(apis.getUsers);
    yield put(usersFetchSucceeded(users));
  } catch (ex) {
    yield put(usersFetchFailed(ex));
  }
}

apisモジュール(apis.ts)からPromiseを返す関数をimportしてcallし、その結果をactionsモジュールからimportしたAction Creatorに渡してActionを生成し、putでStoreにdispatchする。 これが基本形。

以前の記事と違うのは、SagaReturnTypeを使って非同期APIコールのPromiseが解決する値の型を抽出して使っているところ。 apisモジュールを*でインポートしているのは、apisモジュールの関数名がsagasモジュールのものと被りやすいので名前空間を分ける意図なんだけど、そこは別にどうでもいい。

Ajax通信

apis.tsにはAjax通信を実行してPromiseを返す関数を書いておく。

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

import axios from 'axios';
import { User, validateUserList } from './models';

export const client = axios.create({
  timeout: 2000,
});

export const API_USERS = '/api/v1/users';

export const getUsers = () =>
  client.get<User[]>(API_USERS).then((res) => validateKiyoshiList(res.data));

ここではAjaxライブラリにaxiosを使っているけど、Promise返すならなんでもいい。

axios使う場合は、Ajax通信を実行する関数(この例だとget())の型パラメータでPromiseが解決する値の型を指定できる。 Ajax通信のレスポンスが実際にその型であることを保証するために、ここでバリデーションをしておくべし。 上の例のようにモデルオブジェクトを取得するような場合、モデルオブジェクトのバリデーションはmodelsモジュール(models.ts)の責務なので、そこからバリデーション関数をimportして使う形になる。

models.tsについてはまた別の記事で書く。

Watcher Saga

Watcher SagaはwatcherSagas.tsに書く。

import { takeLeading } from 'redux-saga/effects';
import {
  usersBeingFetched,
} from './actions';
import { fetchUsers } from './sagas';

export function* watchUsersBeingFetched() {
  yield takeLeading(
    ({ type }: Pick<ReturnType<typeof usersBeingFetched>, 'type'>) =>
      type === 'user/entitiesBeingFetched',
    fetchUsers,
  );
}

Actionをwatchするのに、ここではtakeLeading使ってるけど、他にも使えるAPIはいくつもあるのでそこは要件に合わせて。 いずれもwatchするActionのtypeを第一引数に指定するようなAPIで、単なる文字列で指定することもできるけど、文字列リテラル型による補完と型チェックを利かせたくて上記例のようにしている。 つまり、第一引数に関数を渡すと、その関数にはdispatchされたActionが渡されるので、そこからtypeを抽出しつつ型を付けて、type === 'user/entitiesBeingFetched'のところで補完と型チェックを利かせている。

watcherSagas.tsにはこのようなwatcher関数を処理したいActionの数だけ書いてexportしておく。 逆に、Watcher Sagaじゃないものはこのモジュールからはexportしない。 これは次節のindex.tsで再exportしやすくするため。

Watcher Sagaをduckからexport

Watcher Sagaは、watcherSagas.tsがあるディレクトリに置いたindex.tsでまとめて再exportしておくと捗る。

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

+import * as watcherSagas from './watcherSagas';
+
+export const userWatcherSagas = Object.values(watcherSagas);
+
 export { UserState, userReducer } from './reducers';

一番下の行は前回の記事で書いたRedcuerとStateの再exportなのでここでは気にしなくていい。

duckのディレクトリ内のindex.tsは、上記の3文を書いてしまえば、その後修正の必要性は出てこない。

Sagaの統合

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

src/state/ducks/index.ts:

+import { all, spawn, call } from 'redux-saga/effects';
-import { UserState, userReducer as user } from './user';
-import { ArticleState, articleReducer as article } from './article';
+import { UserState, userReducer as user, userWatcherSagas } from './user';
+import { ArticleState, articleReducer as article, articleWatcherSagas } from './article';

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

 export const reducers = {
   user,
   article,
 };

+export function* rootSaga() {
+  const watchers = [...userWatcherSagas, ...articleWatcherSagas];
+
+  yield all(
+    watchers.map((saga) =>
+      spawn(function* () {
+        while (true) {
+          try {
+            yield call(saga);
+            break;
+          } catch (ex) {
+            console.exception(ex);
+          }
+        }
+      }),
+    ),
+  );
+}

StoreStatereducers前回の記事で書いたRedcuerとStateの統合なのでここでは気にしなくていい。

各duckのWatcher Sagaの束をimportして、rootSaga()にそれらをまとめてcallする処理を書いている。 これはredux-sagaのマニュアルで紹介されているRoot Saga Patternのひとつで、watcher Sagaが何かしらのエラーで死んでしまったときにも再起動して動き続けられる仕組み。

このrootSaga()をexportしておいて、store.tsで生成するSaga Middlewareで実行する。

src/state/store.ts:

 import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
+import createSagaMiddleware from 'redux-saga';
-import { reducers } from './ducks';
+import { reducers, rootSaga } from './ducks';

 const rootReducer = combineReducers(reducers);
+const sagaMiddleware = createSagaMiddleware();

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

これでSagaも動くようになった。

store.tsはここまで書けば塩漬けできる。