Reduxのモジュールアーキテクチャパターンre-ducksの実践 ― Saga
Mon, Jul 13, 2020
react redux re-ducks typescript redux-sagaTable of Contents
2018年後半にスクラッチから作ったReactとReduxのプロジェクトテンプレートを2020年版として色々アップデートしているなかで、re-ducksパターンに則ってステート管理のモジュール構成を整理しなおしたり、ステート管理に使うライブラリを見直したりした。
この記事では、前回に続いて、React-Redux、Redux Saga、immer、normalizr、reselectを使ったre-ducksパターンの実践について書く。
言語はTypeScript。
モジュール構成
次節以降の解説の前提として、React・Redux・React-Redux・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
- user/
- natures/
- user/
- UserListView.tsx
- article/
- ArticleListView.tsx
- user/
- 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.ts
とapis.ts
とwatcherSagas.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);
+ }
+ }
+ }),
+ ),
+ );
+}
StoreState
とreducers
は前回の記事で書いた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
はここまで書けば塩漬けできる。