Redux saga is a middleware between an application and redux store, that is handled by redux actions. This means, it can listen to actions, intercept actions it is interested in and replace them with other actions. This may be useful for plenty of things. In particular, using sagas you can keep your components as simple as possible and move all the logic to sagas.
When you start working with Redux saga, it seems quite natural to write sagas that describe behavior of a single component or a single page. And it works fine in simple applications that have two or three pages and everything is loaded synchronously. But when a project grows, this may lead you to a mess in sagas department, where sagas don’t have clear purposes and duplicate each other in many ways. It will be difficult to manage, debug and make even small changes.
The thing is that sagas’ work should be coordinated. After some time of struggling with this mess and finally reading the documentation, we in DashBouquet came up with a new approach, which has always been there. Describe a full flow in a saga. This is what the documentation implies, you should just read carefully. Let’s see, what it looks like.
It looks like a flow, isn’t it? Sure, describing full flow in saga doesn’t mean that there should be only one saga. The point is that everything is handled from one place.
Now, let’s take a look at an actual example. Say, in our app a user is able to fill up a job application and send it to the server for further processing. We want to create a saga that will describe full flow of this process.
Everything starts on /create_application_page route when a user clicks an “Apply for a job” button and is redirected to the application form.
import {take, call, put, select, fork, cancel, race} from 'redux-saga/effects';
import {LOCATION_CHANGE, push} from 'react-router-redux';
import {takeEvery} from "redux-saga";
import * as api from './api';
function* createApplication(data) {
yield put(push(`/application_form`));
const { saveApplication } = yield race({
saveApplication: take(SAVE_APPLICATION),
cancelApplication: take(CANCEL_APPLICATION),
});
if (saveApplication) {
const { data } = saveApplication;
yield api.saveApplication(data);
yield put(push(`/application_saved`));
} else {
yield put(push(`/application_cancaled`));
}
}
function* watchCreateApplication() {
const createApplicationTask = takeEvery(CREATE_APPLICATION, createApplication);
while (true) {
const {payload: {pathname}} = yield take(LOCATION_CHANGE);
if (pathname.search(/^\/create_application_page\/?$/) !== -1) {
yield cancel(createApplicationTask);
break;
}
}
}
Here CREATE_APPLICATIONis an action dispatched when a user clicks “Apply for a job”. Then he is redirected to another page with a form. After the user fills up the form and clicks Submit or Cancel, he is redirected further depending on which action was dispatched. And data is sent to the server only if the user clicks Submit.
Also the user may change his mind in the middle of filling up the form and just go by another link without clicking “Cancel”. In this case if he later comes back to /create_application_page where the saga is injected asynchronously, it will be injected one more time, and the data on the server will be created twice, while the user intended to do it only once. We certainly don’t want that, so we need to cancel the task on LOCATION_CHANGE dispatched by redux-router, hence checking the pathname in watchCreateApplication.
And just to make things a bit more complicated, we assume, that in the middle of filling up the form, the user can call a dialog with another form, in which he can choose, say, a country and a city he is applying from and submit this data to the server. We can do this by simply forking another saga that would be quite similar to createApplication saga:
function* chooseLocation() {
yield put(showDialog());
const { saveLocation } = yield race({
saveLocation: take(SAVE_LOCATION),
cancelLocation: take(CANCEL_LOCATION),
});
if (saveLocation) {
const { data } = saveLocation;
yield api.saveLocation(data);
}
yield put(closeDialog());
}
function* createApplication() {
yield put(push(`/application_form`));
const createLocationTask = takeEvery(CREATE_LOCATION, createLocation)
...
yield cancel(createLocationTask);
}
Here we handle the dialog with showDialog and closeDialog sagas. In two words, they should set some value in the store to tell a page, if the dialog has to be shown or hidden at the moment. In more detail it is a subject for another article.
It is important not to forget to cancel the forked tasks. If at some point you find an avalanche of requests going to the server when it was supposed to be only one or two, you probably missed this somewhere.
It is a quite simple example. A flow may be much more complicated, with any amount of subflows (though it might be a bad idea from UX point of view). But anyway, what this approach gives is that you can build your flow step by step. You can make changes later quite easily and they will not lead to unexpected behavior from other sagas. You can make it more complicated, if you need to, but no matter, how many forks your flow has, you never get lost in it.
More tutorials in this series:
Using Normalizr to Organize Data in Stores – Practical Guide
Usage of Reselect in a React-Redux Application