In reality, for a real world application, just consuming an API end point and displaying the data is not going to cut it.
Let’s look at the potential issues we might face in a real world application. Every time you make a request to the server, the following happens.
- Handle usage tracking metrics.
- Display a loading screen until a response is delivered.
- Update caches
- Handle server errors
- Handle server timeouts
This looks complicated than we thought it would be.
What are Sagas?
Sagas manage the flow control by removing the complexity and the mess, by organizing the actions.
Sagas will convert your stories into testable instructions including promises and callbacks.
Generator Functions
Sagas are written using generator functions – A function keyword followed by an asterix.
function *loadData() {
...
}
Generators are functions which can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances.
Generators are functions which can be exited and later re-entered. Their context (variable bindings) will be saved across re-entrances. – MDN
The sagas are made up with a list of steps. We also have to connect the Saga middleware to the Redux store.
function *loadData() {
yield 'step 1 - send request'
yield 'step 2 - show waiting modal'
yield 'step 3 - send analytics data'
yield 'step 4 - update model'
yield 'step 5 - hide waiting modal'
}
In the redux-saga library, we get few helper functions which we can yield.
- call – an effect that will execute an async function eg:- Promise
- put – an effect that will dispatch an action to the Redux Store
Let’s rewrite this function using Redux.
loadData() => {
return dispatch => {
dispatch({ type: 'FETCHING_DATA' });
dispatch({ type: 'SHOW_WAITING_MODAL' });
dispatch({ type: 'SEND_ANALYTICS_DATA' });
fetch('/data').then(data => {
dispatch({ type: 'FETCHED_DATA', payload: data });
dispatch({ type: 'HIDE_WAITING_MODAL' });
});
}
}
This would be a simple thunk action creator for implemented with redux. Problem with this is, in order to test, we need to create a mock to test the fetch
.
Now let’s implement this using Sagas.
import { call, put } from 'redux-saga';
function* loadData() {
yield put({ type: 'FETCHING_DATA' });
yield put({ type: 'SHOW_WAITING_MODAL' });
yield put({ type: 'SEND_ANALYTICS_DATA' });
const data = yield call(fetch, '/data');
yield put({ type: 'FETCHED_DATA', payload: data });
yield put({ type: 'HIDE_WAITING_MODAL' });
}
Let’s break this down with plain English:
- Dispatch a FETCHING_DATA action through Redux.
- Dispatch a SHOW_WAITING_MODAL action through Redux to the view for showing the modal.
- Dispatch a SEND_ANALYTICS_DATA action through Redux.
- Call the API for data and get back a response.
- Dispatch a FETCHED_DATA action through Redux to indicate the data has been received.
- Dispatch a HIDE_WAITING_MODAL action through Redux to indicate the view to hide the modal.
This is nice and concise and describes a flow that’s easy to understand.
Testing Sagas
Now let’s see how we can test this saga. It is actually quite easy.
import { call, put } from 'redux-saga';
const saga = loadData();
const data = [{ message: 'text', done: false }];
saga.next();
expect(saga.next().value).toEqual(
put({ type: 'FETCHING_DATA' })
);
expect(saga.next().value).toEqual(
call(fetch, '/data')
);
expect(saga.next(data).value).toEqual(
put({ type: 'FETCHED_DATA', payload: data })
);
Trigger a Saga
Let’s look at how we can bind our saga to the Redux application.
import { createStore, applyMiddleware } from 'redux';
import sagaMiddleware from 'redux-saga';
const createStoreWithSaga = applyMiddleware(
sagaMiddleware([loadData])
)(createStore);
const store = createStoreWithSaga(reducer, initialState);
Combining Sagas
Composing sagas is easy because saga is an effect
(take, fork etc.).
If we were to trigger our saga each time a special action is dispatched to the store, rather than at startup, we can change our code like below:
import { fork, take } from 'redux-saga';
function* loadData() {
yield put({ type: 'FETCHING_DATA' });
const todos = yield call(fetch, '/data');
yield put({ type: 'FETCHED_DATA', payload: todos });
}
function* watchData() {
while (yield take('FETCH_DATA')) {
yield fork(loadTodos);
}
}
// update our root saga
const createStoreWithSaga = applyMiddleware(
sagaMiddleware([watchData])
)(createStore);
We are using two effects provided by redux-saga:
- take – an effect that waits for a redux action to be dispatched
- fork – an effect that will trigger another effect but does not wait for the end of sub effect to continue