レシピ
スロットリング
便利な組み込みの`throttle`ヘルパーを使用して、ディスパッチされたアクションのシーケンスをスロットリングできます。例えば、ユーザーがテキストフィールドに入力している間に、UIが`INPUT_CHANGED`アクションを発行するとします。
import { throttle } from 'redux-saga/effects'
function* handleInput(input) {
// ...
}
function* watchInput() {
yield throttle(500, 'INPUT_CHANGED', handleInput)
}
このヘルパーを使用することで、`watchInput`は500ms間、新しい`handleInput`タスクを開始しませんが、同時に最新の`INPUT_CHANGED`アクションを基盤となる`buffer`に受け入れ続けます。そのため、その間に発生するすべての`INPUT_CHANGED`アクションを見逃すことはありません。これにより、Sagaは500msごとに最大1つの`INPUT_CHANGED`アクションを実行し、後続のアクションも処理できるようになります。
デバウンス
redux-saga@v1からdebounceは組み込みのエフェクトです。
このエフェクトを他の基本エフェクトの組み合わせとしてどのように実装できるかを検討してみましょう。
シーケンスをデバウンスするには、フォークされたタスクに組み込みの`delay`ヘルパーを入れます。
import { call, cancel, fork, take, delay } from 'redux-saga/effects'
function* handleInput(input) {
// debounce by 500ms
yield delay(500)
...
}
function* watchInput() {
let task
while (true) {
const { input } = yield take('INPUT_CHANGED')
if (task) {
yield cancel(task)
}
task = yield fork(handleInput, input)
}
}
上記の例では、`handleInput`はロジックを実行する前に500ms待機します。この間にユーザーが入力すると、さらに`INPUT_CHANGED`アクションが発生します。`handleInput`は`delay`呼び出しでブロックされたままであるため、ロジックの実行を開始する前に`watchInput`によってキャンセルされます。
上記の例は、redux-sagaの`takeLatest`ヘルパーを使用して書き直すことができます。
import { call, takeLatest, delay } from 'redux-saga/effects'
function* handleInput({ input }) {
// debounce by 500ms
yield delay(500)
...
}
function* watchInput() {
// will cancel current running handleInput task
yield takeLatest('INPUT_CHANGED', handleInput);
}
XHR呼び出しの再試行
redux-saga@v1からretryは組み込みのエフェクトです。
このエフェクトを他の基本エフェクトの組み合わせとしてどのように実装できるかを検討してみましょう。
XHR呼び出しを特定の回数だけ再試行するには、遅延を伴うforループを使用します。
import { call, put, take, delay } from 'redux-saga/effects'
function* updateApi(data) {
for (let i = 0; i < 5; i++) {
try {
const apiResponse = yield call(apiRequest, { data })
return apiResponse
} catch (err) {
if (i < 4) {
yield delay(2000)
}
}
}
// attempts failed after 5 attempts
throw new Error('API request failed')
}
export default function* updateResource() {
while (true) {
const { data } = yield take('UPDATE_START')
try {
const apiResponse = yield call(updateApi, data)
yield put({
type: 'UPDATE_SUCCESS',
payload: apiResponse.body,
})
} catch (error) {
yield put({
type: 'UPDATE_ERROR',
error,
})
}
}
}
上記の例では、`apiRequest`は5回再試行され、その間に2秒間の遅延があります。5回目の失敗後、スローされた例外は親sagaによってキャッチされ、`UPDATE_ERROR`アクションがディスパッチされます。
無制限の再試行が必要な場合は、`for`ループを`while (true)`で置き換えることができます。また、`take`の代わりに`takeLatest`を使用することで、最後のリクエストのみが再試行されます。エラー処理に`UPDATE_RETRY`アクションを追加することで、更新が成功しなかったが再試行されることをユーザーに知らせることができます。
import { delay } from 'redux-saga/effects'
function* updateApi(data) {
while (true) {
try {
const apiResponse = yield call(apiRequest, { data })
return apiResponse
} catch (error) {
yield put({
type: 'UPDATE_RETRY',
error,
})
yield delay(2000)
}
}
}
function* updateResource({ data }) {
const apiResponse = yield call(updateApi, data)
yield put({
type: 'UPDATE_SUCCESS',
payload: apiResponse.body,
})
}
export function* watchUpdateResource() {
yield takeLatest('UPDATE_START', updateResource)
}
元に戻す
元に戻す機能は、ユーザーが何をしているか分からないと仮定する前に、まずアクションがスムーズに実行されるようにすることで、ユーザーへの配慮を示しています(リンク)。Reduxのドキュメントでは、`past`、`present`、`future`の状態を含むようにreducerを変更することに基づいた、堅牢な元に戻す方法について説明されています。redux-undoというライブラリもあり、開発者の負担を軽減するために高階関数reducerを作成します。
しかし、この方法は、アプリケーションの以前の状態への参照を保存するため、オーバーヘッドを伴います。
redux-sagaの`delay`と`race`を使用すると、reducerを拡張したり、以前の状態を保存したりせずに、基本的なワンタイムの元に戻す機能を実装できます。
import { take, put, call, spawn, race, delay } from 'redux-saga/effects'
import { updateThreadApi, actions } from 'somewhere'
function* onArchive(action) {
const { threadId } = action
const undoId = `UNDO_ARCHIVE_${threadId}`
const thread = { id: threadId, archived: true }
// show undo UI element, and provide a key to communicate
yield put(actions.showUndo(undoId))
// optimistically mark the thread as `archived`
yield put(actions.updateThread(thread))
// allow the user 5 seconds to perform undo.
// after 5 seconds, 'archive' will be the winner of the race-condition
const { undo, archive } = yield race({
undo: take(action => action.type === 'UNDO' && action.undoId === undoId),
archive: delay(5000),
})
// hide undo UI element, the race condition has an answer
yield put(actions.hideUndo(undoId))
if (undo) {
// revert thread to previous state
yield put(actions.updateThread({ id: threadId, archived: false }))
} else if (archive) {
// make the API call to apply the changes remotely
yield call(updateThreadApi, thread)
}
}
function* main() {
while (true) {
// wait for an ARCHIVE_THREAD to happen
const action = yield take('ARCHIVE_THREAD')
// use spawn to execute onArchive in a non-blocking fashion, which also
// prevents cancellation when main saga gets cancelled.
// This helps us in keeping state in sync between server and client
yield spawn(onArchive, action)
}
}
アクションのバッチ処理
`redux`は、複数のアクションをディスパッチしてreducerを1回だけ呼び出す機能をサポートしていません。これにはパフォーマンス上の問題があり、複数のアクションを順番にディスパッチする必要があるというエルゴノミクスも良くありません。
代わりに、サードパーティライブラリであるredux-batched-actionsを使用します。これは、エンドユーザーが複数のアクションをディスパッチし、reducerを1回だけ呼び出すことを可能にする、シンプルなreducerとアクションです。
同時に多くのアクションをディスパッチする必要があるコードベースがある場合は、このレシピを使用することをお勧めします。
import { configureStore } from '@reduxjs/toolkit';
import createSagaMiddleware, { stdChannel } from 'redux-saga';
import { enableBatching, BATCH } from 'redux-batched-actions';
// your root reducer
import { rootReducer } from './reducer';
// your root saga
import { rootSaga } from './saga';
const channel = stdChannel();
const rawPut = channel.put;
channel.put = (action: ActionWithPayload<any>) => {
if (action.type === BATCH) {
action.payload.forEach(rawPut);
return;
}
rawPut(action);
};
const sagaMiddleware = createSagaMiddleware({ channel });
const reducer = enableBatching(rootReducer);
// https://redux-toolkit.dokyumento.jp/api/configureStore
const store = configureStore({
reducer: rootReducer,
middleware: [sagaMiddleware],
});
sagaMiddleware.run(rootSaga);