Recipes
Throttling
You can throttle a sequence of dispatched actions by using a handy built-inthrottle helper. For example, suppose the UI fires anINPUT_CHANGED action while the user is typing in a text field.
import{ throttle}from'redux-saga/effects'
function*handleInput(input){
// ...
}
function*watchInput(){
yieldthrottle(500,'INPUT_CHANGED', handleInput)
}
By using this helper thewatchInput won't start a newhandleInput task for 500ms, but in the same time it will still be accepting the latestINPUT_CHANGED actions into its underlayingbuffer, so it'll miss allINPUT_CHANGED actions happening in-between. This ensures that the Saga will take at most oneINPUT_CHANGED action during each period of 500ms and still be able to process trailing action.
Debouncing
From redux-saga@v1debounce is built-in effect.
Let's consider how the effect could be implemented as a combination of other base effects.
To debounce a sequence, put the built-indelay helper in the forked task:
import{ call, cancel, fork, take, delay}from'redux-saga/effects'
function*handleInput(input){
// debounce by 500ms
yielddelay(500)
...
}
function*watchInput(){
let task
while(true){
const{ input}=yieldtake('INPUT_CHANGED')
if(task){
yieldcancel(task)
}
task=yieldfork(handleInput, input)
}
}
In the above examplehandleInput waits for 500ms before performing its logic. If the user types something during this period we'll get moreINPUT_CHANGED actions. SincehandleInput will still be blocked in thedelay call, it'll be cancelled bywatchInput before it can start performing its logic.
Example above could be rewritten with redux-sagatakeLatest helper:
import{ call, takeLatest, delay}from'redux-saga/effects'
function*handleInput({ input}){
// debounce by 500ms
yielddelay(500)
...
}
function*watchInput(){
// will cancel current running handleInput task
yieldtakeLatest('INPUT_CHANGED', handleInput);
}
Retrying XHR calls
From redux-saga@v1retry is built-in effect.
Let's consider how the effect could be implemented as a combination of other base effects.
To retry an XHR call for a specific amount of times, use a for loop with a delay:
import{ call, put, take, delay}from'redux-saga/effects'
function*updateApi(data){
for(let i=0; i<5; i++){
try{
const apiResponse=yieldcall(apiRequest,{ data})
return apiResponse
}catch(err){
if(i<4){
yielddelay(2000)
}
}
}
// attempts failed after 5 attempts
thrownewError('API request failed')
}
exportdefaultfunction*updateResource(){
while(true){
const{ data}=yieldtake('UPDATE_START')
try{
const apiResponse=yieldcall(updateApi, data)
yieldput({
type:'UPDATE_SUCCESS',
payload: apiResponse.body,
})
}catch(error){
yieldput({
type:'UPDATE_ERROR',
error,
})
}
}
}
In the above example theapiRequest will be retried for 5 times, with a delay of 2 seconds in between. After the 5th failure, the exception thrown will get caught by the parent saga, which will dispatch theUPDATE_ERROR action.
If you want unlimited retries, then thefor loop can be replaced with awhile (true). Also instead oftake you can usetakeLatest, so only the last request will be retried. By adding anUPDATE_RETRY action in the error handling, we can inform the user that the update was not successful but it will be retried.
import{ delay}from'redux-saga/effects'
function*updateApi(data){
while(true){
try{
const apiResponse=yieldcall(apiRequest,{ data})
return apiResponse
}catch(error){
yieldput({
type:'UPDATE_RETRY',
error,
})
yielddelay(2000)
}
}
}
function*updateResource({ data}){
const apiResponse=yieldcall(updateApi, data)
yieldput({
type:'UPDATE_SUCCESS',
payload: apiResponse.body,
})
}
exportfunction*watchUpdateResource(){
yieldtakeLatest('UPDATE_START', updateResource)
}
Undo
The ability to undo respects the user by allowing the action to happen smoothlyfirst and foremost before assuming they don't know what they are doing (link).Theredux documentation describes arobust way to implement an undo based on modifying the reducer to containpast,present,andfuture state. There is even a libraryredux-undo thatcreates a higher order reducer to do most of the heavy lifting for the developer.
However, this method comes with overhead because it stores references to the previous state(s) of the application.
Using redux-saga'sdelay andrace we can implement a basic, one-time undo without enhancingour reducer or storing the previous state.
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
yieldput(actions.showUndo(undoId))
// optimistically mark the thread as `archived`
yieldput(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}=yieldrace({
undo:take(action=> action.type==='UNDO'&& action.undoId=== undoId),
archive:delay(5000),
})
// hide undo UI element, the race condition has an answer
yieldput(actions.hideUndo(undoId))
if(undo){
// revert thread to previous state
yieldput(actions.updateThread({ id: threadId, archived:false}))
}elseif(archive){
// make the API call to apply the changes remotely
yieldcall(updateThreadApi, thread)
}
}
function*main(){
while(true){
// wait for an ARCHIVE_THREAD to happen
const action=yieldtake('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
yieldspawn(onArchive, action)
}
}
Batching actions
redux does not support the ability to dispatch multiple actions and only callthe reducer once. This has performance implications and the ergonomics ofneeding to dispatch multiple actions sequentially aren't great.
Instead we look to a third-party library,redux-batched-actions. This is asimple reducer and action that allows end-developers to dispatch multipleactions and only have your reducer be called once.
If you have a codebase that needs to dispatch many actions at the same time, werecommend using this recipe.
import{ configureStore}from'@reduxjs/toolkit';
importcreateSagaMiddleware,{ 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.js.org/api/configureStore
const store=configureStore({
reducer: rootReducer,
middleware:[sagaMiddleware],
});
sagaMiddleware.run(rootSaga);