
Reactでredux-saga, Redux-thunkのデータフローを理解する『初心者向けに分かりやすく解説!』
[st-minihukidashi fontawesome=”” fontsize=”” fontweight=”” bgcolor=”#FFB74D” color=”#fff” margin=”0 0 20px 0″ radius=”” add_boxstyle=””]参考記事[/st-minihukidashi]
redux開発者なら誰でも知っているように、アプリ構築で最も難しいのは非同期呼び出しです。ネットワークリクエスト、タイムアウト、その他のコールバックを、reduxのアクションやreducerを複雑にすることなく、どのように処理すればいいでしょうか
この複雑さを管理するために、アプリの非同期性を処理するためのいくつかの異なるアプローチを説明します。redux-thunkのようなシンプルなアプローチから、redux-sagaのようなフル機能を備えたライブラリまで様々です。
この記事ではReactとReduxを使用しますので、これらの仕組みについて最低限の知識を持っていることを前提としています。
アクションクリエーター
APIを呼び出すことは、多くのアプリで共通の要件です。
例えば、ボタンをクリックしたときに、犬の写真をランダムに表示する必要があるとします。
Dog CEO APIや、アクションクリエイターの中でのフェッチコールのようなシンプルなものを使うことができます。
const {Provider, connect} = ReactRedux;
const createStore = Redux.createStore
// Reducer
const initialState = {
url: '',
loading: false,
error: false,
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUESTED_DOG':
return {
url: '',
loading: true,
error: false,
};
case 'REQUESTED_DOG_SUCCEEDED':
return {
url: action.url,
loading: false,
error: false,
};
case 'REQUESTED_DOG_FAILED':
return {
url: '',
loading: false,
error: true,
};
default:
return state;
}
};
// Action Creators
const requestDog = () => {
return { type: 'REQUESTED_DOG' }
};
const requestDogSuccess = (data) => {
return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};
const requestDogaError = () => {
return { type: 'REQUESTED_DOG_FAILED' }
};
const fetchDog = (dispatch) => {
dispatch(requestDog());
return fetch('https://dog.ceo/api/breeds/image/random')
.then(res => res.json())
.then(
data => dispatch(requestDogSuccess(data)),
err => dispatch(requestDogError())
);
};
// Component
class App extends React.Component {
render () {
return (
<div>
<button onClick={() => fetchDog(this.props.dispatch)}>Show Dog</button>
{this.props.loading
? <p>Loading...</p>
: this.props.error
? <p>Error, try again</p>
: <p><img src={this.props.url}/></p>}
</div>
)
}
}
// Store
const store = createStore(reducer);
const ConnectedApp = connect((state) => {
console.log(state);
return state;
})(App);
// Container component
ReactDOM.render(
<Provider store={store}>
<ConnectedApp />
</Provider>,
document.getElementById('root')
);
このアプローチには何の問題もありません、最もシンプルなアプローチを取るべきです。
しかし、Reduxだけを使っても、あまり柔軟性は得られません。
Reduxの核心は、同期的なデータフローをサポートするステートコンテナに過ぎません。
アクション(何が起こったかを記述するプレーンなオブジェクト)がストアに送信されるたびに、
Reducerが呼び出され、ステートが即座に更新されます。
しかし、非同期フローでは、まずレスポンスを待ち、エラーがなければステートを更新する必要があります。
また、アプリケーションが複雑なロジック・ワークフローを持っている場合はどうでしょうか?
Reduxではこの問題を解決するためにミドルウェアを使用しています。
ミドルウェアとは、アクションがディスパッチされた後、リデューサに到達する前に実行されるコードの一部です。
多くのミドルウェアを実行チェーンに配置することで、アクションをさまざまな方法で処理することができます。
ただし、ミドルウェアは、渡されたものを何でも解釈しなければならず、
チェーンの最後にプレーンなオブジェクト(アクション)がディスパッチされるようにしなければなりません。
非同期操作のために、Reduxはredux-thunkというミドルウェアを提供しています。
Redux-thunk
Redux-thunkは、Reduxで非同期操作を行うための標準的な方法です。
thunkは、すぐには呼び出されず、必要なときにのみ呼び出される関数を表します。
redux-thunkのドキュメントにある例を見てみましょう。
値3は直ちにxに代入されます。
しかし、次のような文があるとします。
sum演算はすぐには実行されず、foo()を呼び出したときにのみ実行されます。
これにより、fooはthunkになります。
Redux-thunkでは、アクションクリエイターが単なるオブジェクトに加えて関数をディスパッチすることができ、
アクションクリエイターをthunkに変換します。
先ほどの例をredux-thunkを使ってみるとこのようになります。
const {Provider, connect} = ReactRedux;
const {createStore, applyMiddleware} = Redux;
const thunk = ReduxThunk.default;
// Reducer
const initialState = {
url: '',
loading: false,
error: false,
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUESTED_DOG':
return {
url: '',
loading: true,
error: false,
};
case 'REQUESTED_DOG_SUCCEEDED':
return {
url: action.url,
loading: false,
error: false,
};
case 'REQUESTED_DOG_FAILED':
return {
url: '',
loading: false,
error: true,
};
default:
return state;
}
};
// Action Creators
const requestDog = () => {
return { type: 'REQUESTED_DOG' }
};
const requestDogSuccess = (data) => {
return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};
const requestDogError = () => {
return { type: 'REQUESTED_DOG_FAILED' }
};
const fetchDog = () => {
return (dispatch) => {
dispatch(requestDog());
fetch('https://dog.ceo/api/breeds/image/random')
.then(res => res.json())
.then(
data => dispatch(requestDogSuccess(data)),
err => dispatch(requestDogError())
);
}
};
// Component
class App extends React.Component {
render () {
return (
<div>
<button onClick={() => this.props.dispatch(fetchDog())}>Show Dog</button>
{this.props.loading
? <p>Loading...</p>
: this.props.error
? <p>Error, try again</p>
: <p><img src={this.props.url}/></p>}
</div>
)
}
}
// Store
const store = createStore(
reducer,
applyMiddleware(thunk)
);
const ConnectedApp = connect((state) => {
console.log(state);
return state;
})(App);
// Container component
ReactDOM.render(
<Provider store={store}>
<ConnectedApp />
</Provider>,
document.getElementById('root')
);
一見すると、これまでのアプローチとあまり変わらないように見えるかもしれません。
redux-thunkを使わない場合は下記のようにまりまして、
redux-thunkを使うと下記のようになります。
redux-thunkを使う利点は、コンポーネントが非同期アクションを実行していることを知らないことです。
ミドルウェアは自動的にアクションクリエイターが返す関数にディスパッチ関数を渡すので、
コンポーネントにとっては、同期アクションと非同期アクションの実行を要求することに違いはありません(そして、どちらにしても気にする必要はありません)。
ミドルウェアを使用することで、より柔軟性のあるインダイレクトのレイヤーを追加しています。
redux-thunkはディスパッチされた関数に、ストアからのdispatchメソッドとgetStateメソッドをパラメータとして与えるので、
他のアクションをディスパッチしたり、ステートを読んだりして、より複雑なビジネスロジックやワークフローを実装することもできます。
もう一つの利点は、複雑すぎてthunksで表現できないものがある場合、コンポーネントを変更せずに、別のミドルウェアライブラリを使用してより多くの制御を行うことができることです。
Redux-saga
Redux-sagaは、sagasと連携することで、副作用をより簡単に、より良くすることを目的としたライブラリです。
sagaは分散型トランザクションの世界から来たデザインパターンで、トランザクション的に実行される必要のあるプロセスを管理し、実行の状態を保持し、失敗したプロセスを補償します。
sagaについて詳しく知りたい方は、まずCaitie McCaffrey氏の「Applying the Saga Pattern」をご覧ください。野心的な方は、分散システムに関連してsagaを最初に定義した論文をご覧ください。
Reduxの文脈では、sagaは、非同期アクション(サイドエフェクト)を調整してトリガーするためのミドルウェアとして実装されています(これは純粋な関数でなければならないので、reducerを使うことはできません)。
ジェネレータは、ES6で導入されたものですが、
関数のすべてのステートメントを一度に実行するのではなく、一時停止したり再開したりできる関数です。
ジェネレータ関数を起動すると、イテレータオブジェクトが返されます。
イテレータのnext()メソッドが呼び出されるたびに、
ジェネレータの本体は次のyield文まで実行され、その後、一時停止します。
これにより、非同期のコードを簡単に書くことができ、理解しやすくなります。例えば、このようにする代わりに
ジェネレータがあれば、こんなこともできます。
redux-sagaの話に戻りますが、
一般的にはディスパッチされたアクションを監視することを
仕事とするsagaがあります。
sagaの内部に実装したいロジックを調整するために、
takeEveryのようなヘルパー関数を使って、新しいsagaを生成して操作を行うことができます。
複数のリクエストがあった場合、takeEveryはワーカーsagaの複数のインスタンスを起動します。言い換えれば、並行処理をしてくれるということです。
ウォッチャーsagaは、複雑なロジックを実装するための柔軟性を提供する、もう一つの間接的なレイヤーであることに注意してください (ただし、シンプルなアプリケーションには不要かもしれません)。
さて、fetchDogAsync()関数を次のように実装することができます(ディスパッチメソッドにアクセスできると仮定して)。
こうできますが、一点、
redux-sagaでは、操作を実行した結果そのものをもたらすのではなく、
操作を実行する意図を宣言するオブジェクトをもたらすことができます。
つまり、上記の例はredux-sagaではこのように実装されています。
非同期リクエストを直接呼び出すのではなく、メソッドコールは操作を記述したプレーンなオブジェクトだけを返すので、
redux-sagaが呼び出しを処理し、その結果をジェネレーターに返します。
同じことが put メソッドでも起こります。putは、ジェネレーター内でアクションをディスパッチする代わりに、
ミドルウェアがアクションをディスパッチするための命令を持つオブジェクトを返します。
これらの返されたオブジェクトはEffectと呼ばれます。以下は、callメソッドが返すEffectの例です。
エフェクトを使うことで、redux-sagaはsagを命令型ではなく宣言型にします。
宣言型プログラミングとは、プログラムが達成すべきことを記述する代わりに、それを達成する方法を記述することで、副作用を最小限または排除しようとするプログラミングスタイルです。
宣言型プログラミングの利点は、非同期呼び出しを直接行う関数よりも、単純なオブジェクトを返す関数の方がテストしやすいという点にあります。
テストを実行するために、本物のAPIを使う必要も、偽物を使う必要も、モックを使う必要もありません。
テストでは、ジェネレータ関数を繰り返し実行し、得られた値が等しいかどうかをアサートするだけです。
しかし、もう一つの利点は、多くのエフェクトを複雑なワークフローに簡単に合成できることです。
takeEvery、call、putに加えて、redux-sagaは、スロットリング、現在の状態の取得、タスクの並列実行、タスクのキャンセルなど、多くのエフェクトクリエーターを提供しています。
例題に戻りますが、これがredux-sagaでの完全な実装です。
const {Provider, connect} = ReactRedux;
const {createStore, applyMiddleware} = Redux;
const createSagaMiddleware = ReduxSaga.default;
// 2020-10-01: Updated to the latest version of Redux thanks to Alex Mouton (https://github.com/alexmouton)
const {takeEvery, put, call} = ReduxSaga.effects;
// Reducer
const initialState = {
url: '',
loading: false,
error: false,
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case 'REQUESTED_DOG':
return {
url: '',
loading: true,
error: false,
};
case 'REQUESTED_DOG_SUCCEEDED':
return {
url: action.url,
loading: false,
error: false,
};
case 'REQUESTED_DOG_FAILED':
return {
url: '',
loading: false,
error: true,
};
default:
return state;
}
};
// Action Creators
const requestDog = () => {
return { type: 'REQUESTED_DOG' }
};
const requestDogSuccess = (data) => {
return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};
const requestDogError = () => {
return { type: 'REQUESTED_DOG_FAILED' }
};
const fetchDog = () => {
return { type: 'FETCHED_DOG' }
};
// Sagas
function* watchFetchDog() {
yield takeEvery('FETCHED_DOG', fetchDogAsync);
}
function* fetchDogAsync() {
try {
yield put(requestDog());
const data = yield call(() => {
return fetch('https://dog.ceo/api/breeds/image/random')
.then(res => res.json())
}
);
yield put(requestDogSuccess(data));
} catch (error) {
yield put(requestDogError());
}
}
// Component
class App extends React.Component {
render () {
return (
<div>
<button onClick={() => this.props.dispatch(fetchDog())}>Show Dog</button>
{this.props.loading
? <p>Loading...</p>
: this.props.error
? <p>Error, try again</p>
: <p><img src={this.props.url}/></p>}
</div>
)
}
}
// Store
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
);
sagaMiddleware.run(watchFetchDog);
const ConnectedApp = connect((state) => {
console.log(state);
return state;
})(App);
// Container component
ReactDOM.render(
<Provider store={store}>
<ConnectedApp />
</Provider>,
document.getElementById('root')
);
このボタンをクリックすると、次のようになります。
- アクション FETCHED_DOG がディスパッチされます。
- ウォッチャー・サーガ(watchFetchDog)はディスパッチされたアクションを受け取り、ワーカー・サーガ(fetchDogAsync)を呼び出します。
- ローディングインジケータを表示するアクションがディスパッチされる
APIコールが実行される - 状態を更新するアクションがディスパッチされる(成功または失敗)
- もしあなたが、いくつかのインダイレクトのレイヤーと少しの追加作業が価値があると思うなら、redux-sagaは機能的な方法で副作用を処理するためのコントロールを与えることができます。
まとめ
この記事では、アクションクリエイター、サンク、サガを使ってReduxで非同期操作を実装する方法を、最もシンプルなアプローチから最も複雑なアプローチまで紹介しました。
Reduxは、副作用を処理するためのソリューションを規定していません。どのアプローチを取るかを決める際には、アプリケーションの複雑さを考慮する必要があります。私のお勧めは、最もシンプルなソリューションから始めることです。
redux-sagaには他にも試す価値のある選択肢があります。最も人気のある2つの選択肢は、redux-observable(RxJSベース)とredux-logic(同じくRxJS observableベースだが、ロジックを他のスタイルで書くことができる)です。