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);
}