React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その8: Redux-Saga
Sun, Oct 7, 2018
react frontend redux redux-sagaTable of Contents
ReactとReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)
前回はReact Reduxをセットアップした。
(2018/11/21更新)
ReduxのMiddleware
Redux単体では同期的なデータフローしか実装できない。 つまり、Actionを発生させたら、即座にディスパッチされ、stateが更新される。 一方、非同期なフローとは、REST APIを呼んでその結果でstateを更新するような処理。 REST API呼び出しが非同期なわけだが、これをReduxのピュアなフローのどこで実行するのかというと、Middlewareで実行する。
MiddlewareはStoreのdispatch()
をラップして、Actionをトラップして副作用を含む任意の処理をするための機能。
Middlewareの仕組みについてはこの記事が分かりやすい。
Middlewareには例えば、発生したActionの内容と、それによるstateの変化をログに出力するredux-loggerがある。 デバッグに有用そうなので入れておく。
$ yarn add redux-logger
v3.0.6が入った。
Middlewareは、ReduxのapplyMiddleware()
というAPIを使って、createStore()
実行時に適用できる。
src/configureStore.js
:
-import { createStore } from 'redux';
+import { createStore, applyMiddleware } from 'redux';
+import { logger } from 'redux-logger';
import rootReducer from './reducers/rootReducer';
export default function configureStore(initialState = {}) {
+ const middlewares = [];
+ if (process.env.NODE_ENV === `development`) {
+ middlewares.push(logger);
+ }
+
const store = createStore(
rootReducer,
initialState,
+ applyMiddleware(...middlewares),
);
return store;
}
これだけ。
これで、HOGEボタンをクリックしたときにコンソールに以下のようなログが出るようになる。
(ログはyarn start
とかの開発モードの時だけでる。)
action HOGE_BUTTON_CLICKED @ 23:19:35.190
prev state Object { hoge: {…} }
action Object { type: "HOGE_BUTTON_CLICKED", payload: undefined }
next state Object { hoge: {…} }
非同期処理
非同期処理をするためのMiddlewareにはredux-thunkとかredux-promiseとかがあるけど、なかでもGitHubのスター数が一番多いRedux Sagaを使うことにする。
$ yarn add redux-saga
v0.16.2が入った。
因みに次にスター数が多いのがredux-thunkで、これはActionをfunctionオブジェクトで書けるようにするMiddleware。 そのfunctionの中で非同期処理をすることで、非同期なReduxフローを実現できる。 redux-sagaはredux-thunkに比べて以下の特長を持つ。
- コールバック地獄に悩まされることが無い
- Actionをプレーン且つピュアに保てるのでテストしやすい
Redux Sagaの使い方
Redux Sagaでは、非同期処理はSagaというコンポーネントに書く。 Sagaでは、
- ディスパッチされるActionをWatcherが監視し、
- 特定のActionが来たらWorkerを起動し、
- Workerが非同期処理などのTaskを実行し、
- その結果を通知するActionをディスパッチする、
といった処理を実行する。
これらの処理は、Saga Middlewareから呼ばれるジェネレータ関数のなかで、EffectというオブジェクトをSaga Middlewareに返すことで、Saga Middlewareに指示して実行させる。 このEffectを生成するAPIがRedux Sagaからいろいろ提供されている。
上記処理の1~4はそれぞれ以下のAPIで実装できる。
take(pattern)
: ディスパッチされるActionを監視して、pattern
にマッチしたら取得するEffectを生成する。fork(fn, ...args)
: 渡された関数fn
をノンブロッキングで呼び出すEffectを生成する。fn
はジェネレータかPromiseを返す関数。call(fn, ...args)
: 渡された関数fn
を同期的に呼び出すEffectを生成する。fn
はジェネレータかPromiseを返す関数。put(action)
: Actionオブジェクトのaction
をディスパッチするEffectを生成する。
REST API呼び出し
非同期実行で最もよくあるのがREST API呼び出しであろう。
REST API呼び出し処理はcall()
で実行するわけだけど、call()
にはPromiseを返す必要があるので、使うライブラリはそこを考慮しないといけない。
ざっと調べたところ、axios、SuperAgent、r2あたりが選択肢。 最も人気のあるaxiosを使うことにする。
$ yarn add axios
v0.18.0が入った。
REST API呼び出しのコードはsrc/services/
に置く。
src/services/api.js
:
import axios from 'axios';
export const HOGE_URL = 'https://httpbin.org/get';
export function getHoge() {
return axios.get(HOGE_URL);
}
getHoge()
はGETリクエストを送ってPromiseオブジェクトを返す。
このPromiseオブジェクトはレスポンスボディやステータスコードを保持するResponseオブジェクトに解決される。
REST API呼び出しを表現するAction
REST API呼び出しをする場合、呼び出し開始、呼び出し成功、呼び出し失敗の3種類のActionで表現するのが一つのプラクティス。 これら3種類を、同一のtypeのActionのプロパティ値を変えて表現するやりかたもあるけど、ここでは別々のtypeのアクションとする。
src/actions/actionTypes.js
:
export const HOGE_BUTTON_CLICKED = 'HOGE_BUTTON_CLICKED';
+export const HOGE_FETCH_SUCCEEDED = 'HOGE_FETCH_SUCCEEDED';
+export const HOGE_FETCH_FAILED = 'HOGE_FETCH_FAILED';
src/actions/actions.js
:
import {
HOGE_BUTTON_CLICKED,
+ HOGE_FETCH_SUCCEEDED,
+ HOGE_FETCH_FAILED,
} from './actionTypes';
export function hogeButtonClicked() {
return {
type: HOGE_BUTTON_CLICKED,
};
}
+
+export function hogeFetchSucceeded(payload, meta) {
+ return {
+ type: HOGE_FETCH_SUCCEEDED,
+ payload,
+ meta,
+ };
+}
+
+export function hogeFetchFailed(payload) {
+ return {
+ type: HOGE_FETCH_FAILED,
+ error: true,
+ payload,
+ };
+}
Sagaの実装
Sagaのソースはsrc/sagas/
に置く。
HOGE_BUTTON_CLICKED
が来たらgetHoge()
を実行するSagaは以下のような感じ。
src/sagas/hoge.js
:
import { call, fork, put, take } from 'redux-saga/effects';
import { getHoge } from '../services/apis';
import { HOGE_BUTTON_CLICKED } from '../actions/actionTypes';
import { hogeFetchSucceeded, hogeFetchFailed } from '../actions/actions';
// Task
function* fetchHoge() {
try {
const response = yield call(getHoge);
const payload = response.data;
const meta = { statusCode: response.status, statusText: response.statusText };
yield put(hogeFetchSucceeded(payload, meta));
} catch (ex) {
yield put(hogeFetchFailed(ex));
}
}
// Watcher
export function* watchHogeButtonClicked(): Generator<any, void, Object> {
while (true) {
const action = yield take(HOGE_BUTTON_CLICKED);
yield fork(fetchHoge, action); // actionはfetchHogeの引数に渡される。使ってないけど…
}
}
Watcherはtake
してfork
するのを無限ループで回すのが常なので、これをもうちょっときれいに書けるAPIが用意されていて、以下のように書ける。
import { takeEvery } from 'redux-saga/effects'
// Watcher
export function* watchHogeButtonClicked(): Generator<any, void, Object> {
yield takeEvery(HOGE_BUTTON_CLICKED, fetchHoge)
}
この場合、fetchHoge()
の最後の引数にtake
したActionオブジェクトが渡される。
で、今後Watcherはモジュールを分けていくつも書いていくことになるので、それらをまとめて起動するためのモジュールrootSaga.js
を作って、そこで各Watcherをimport
してcall()
したい。
call()
はブロッキングなAPIなので、パラレルに実行するためにall()
を使う。
src/sagas/rootSaga.js
:
import { call, all } from 'redux-saga/effects';
import { watchHogeButtonClicked } from './hoge';
export default function* rootSaga() {
yield all([
call(watchHogeButtonClicked),
// call(watchAnotherAction),
// call(watchYetAnotherAction),
]);
}
そもそもブロッキングなcall()
を使うのがだめなので、代わりにfork()
を使ってもいい。
src/sagas/rootSaga.js
:
import { fork } from 'redux-saga/effects';
import { watchHogeButtonClicked } from './hoge';
export default function* rootSaga() {
yield fork(watchHogeButtonClicked);
// yield fork(watchAnotherAction);
// yield fork(watchYetAnotherAction);
}
どっちがいいんだろう。
Saga Middlewareの追加と起動
Saga Middlewareは以下のように追加して起動する。
src/configureStore.js
:
import { createStore, applyMiddleware } from 'redux';
+import createSagaMiddleware from 'redux-saga';
import { logger } from 'redux-logger';
+import rootSaga from './sagas/rootSaga';
import rootReducer from './reducers/rootReducer';
+const sagaMiddleware = createSagaMiddleware();
export default function configureStore(initialState = {}) {
const middlewares = [];
if (process.env.NODE_ENV === `development`) {
middlewares.push(logger);
}
+ middlewares.push(sagaMiddleware);
const store = createStore(
rootReducer,
initialState,
applyMiddleware(...middlewares),
);
+ sagaMiddleware.run(rootSaga);
return store;
}