Recipes
Throttling
You can throttle a sequence of dispatched actions by putting a delay inside a watcher Saga. For example, suppose the UI fires an INPUT_CHANGED
action while the user is typing in a text field.
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
function* handleInput(input) {
// ...
}
function* watchInput() {
while (true) {
const { input } = yield take('INPUT_CHANGED')
yield fork(handleInput, input)
// throttle by 500ms
yield call(delay, 500)
}
}
By putting a delay after the fork
, the watchInput
will be blocked for 500ms so it'll miss all INPUT_CHANGED
actions happening in-between. This ensures that the Saga will take at most one INPUT_CHANGED
action during each period of 500ms.
But there is a subtle issue with the above code. After taking an action, watchInput
will sleep for 500ms, which means it'll miss all actions that occurred in this period. That may be the purpose for throttling, but note the watcher will also miss the trailer action: i.e. the last action that may eventually occur in the 500ms interval. If you are throttling input actions on a text field, this may be undesirable, because you'll likely want to react to the last input after the 500ms throttling delay has passed.
Here is a more elaborate version which keeps track of the trailing action:
function* watchInput(wait) {
let lastAction
let lastTime = Date.now()
let countDown = 0 // handle leading action
while (true) {
const winner = yield race({
action: take('INPUT_CHANGED'),
timeout: countDown ? call(delay, countDown) : null
})
const now = Date.now()
countDown -= (now - lastTime)
lastTime = now
if (winner.action) {
lastAction = action
}
if (lastAction && countDown <= 0) {
yield fork(worker, lastAction)
lastAction = null
countDown = wait
}
}
}
In the new version, we maintain a countDown
variable which tracks the remaining timeout. Initially the countDown
is 0
because we want to handle the first action. After handling the first action, the countDown
will be set to the throttling period wait
. Which means we'll have to wait at least for wait
ms before handling a next action.
Then at each iteration, we start a race between the next eventual action and the remaining timeout. Now we don't miss any action, instead we keep track of the last one in the lastAction
variable, and we also update the countDown with remaining timeout.
The if (lastAction && countDown <= 0) {...}
block ensures that we can handle an eventual trailing action (if lastAction
is not null/undefined) after the throttling period expired (if countDown
is less or equal than 0). Immediately after handling the action, we reset the lastAction
and countDown
. So we'll now have to wait for another wait
ms period for another action to handle it.
Debouncing
To debounce a sequence, put the delay
in the forked task:
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
function* handleInput(input) {
// debounce by 500ms
yield call(delay, 500)
...
}
function* watchInput() {
let task
while (true) {
const { input } = yield take('INPUT_CHANGED')
if (task) {
yield cancel(task)
}
task = yield fork(handleInput, input)
}
}
In the above example handleInput
waits for 500ms before performing its logic. If the user types something during this period we'll get more INPUT_CHANGED
actions. Since handleInput
will still be blocked in the delay
call, it'll be cancelled by watchInput
before it can start performing its logic.
Example above could be rewritten with redux-saga takeLatest
helper:
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
function* handleInput({ input }) {
// debounce by 500ms
yield call(delay, 500)
...
}
function* watchInput() {
// will cancel current running handleInput task
yield* takeLatest('INPUT_CHANGED', handleInput);
}