Succinct Sagas with Redux for Enterprise

Nicole Chung
Inside League
Published in
6 min readAug 1, 2019

At League, we use redux-saga to handle our side effects, mainly communicating with our API.

If you have ever used redux-saga, you might have noticed that you write the same things over and over. We ran into this problem at League:

  • dispatching an action to get some data
  • dispatching an action to START the request to get the data
  • dispatching a SUCCESS action once you get the data
  • dispatching an ERROR action if the request fails
  • dispatching a CANCEL action if the request is canceled.

Below are some snippets of how Gavin, a Senior Front-end Engineer at League, and some of our other developers abstracted away some of our boilerplate saga code.

The goal is to write the smallest generator functions. Smaller functions with a single responsibility are easier to maintain and test, and also keeps developers happy.

Requests

Let’s say we have a page where we fetch a user’s documents. A sample request looks something like this:

Action Types

export const FETCH_DOCUMENTS = 'documents/FETCH_DOCUMENTS'export const REQUEST_DOCUMENTS_STARTED = 'documents/REQUEST_DOCUMENTS_STARTED'export const REQUEST_DOCUMENTS_SUCCEEDED = 'documents/REQUEST_DOCUMENTS_SUCCEEDED'export const REQUEST_DOCUMENTS_ERRORED = 'documents/REQUEST_DOCUMENTS_ERRORED'export const REQUEST_DOCUMENTS_CANCELLED = 'documents/REQUEST_DOCUMENTS_CANCELLED'

Action Creator

export const fetchDocuments = () => ({
type: FETCH_DOCUMENTS,
});

Worker and Watcher Saga

// worker saga
export function* requestDocuments() {
// start, success, error and cancel are action creators
const { start, success, error, cancel } = type;
yield put({type: REQUEST_DOCUMENTS_STARTED});
try {
const response = yield call(someAPIRequest);
yield put(success({type: REQUEST_DOCUMENTS_SUCCEEDED, payload: response}));
return response;
} catch (caught) {
return yield put(error({type: REQUEST_DOCUMENTS_ERRORED}));
} finally {
if (yield cancelled()) {
yield put(cancel({type: REQUEST_DOCUMENTS_CANCELLED}));
}
}
}
// watcher saga
export function documentsSaga() {
yield fork(takeEvery, FETCH_DOCUMENTS, requestDocuments)
}

If we do this for every single API request, we introduce a few problems:

  1. There is lots of duplicate code. For example, the same kind of try/catch block gets written over and over again. We also end up creating the same action types (START, SUCCESS, ERROR, CANCEL) over and over.
  2. This function is doing several things — but we’d like it to do one thing (have a single responsibility).
  3. Longer tests to write because your generators functions are large.

The above means it takes our frontend developers longer to get used to making API requests in our React app.

Instead, Gavin wrote some helper functions that look like this:

import { isArray } from 'lodash';
import { call, cancelled, put } from 'redux-saga/effects';
export function createRequestTypes(type) {
const BASE = type;
const STARTED = `${type}_STARTED`;
const SUCCEEDED = `${type}_SUCCEEDED`;
const ERRORED = `${type}_ERRORED`;
const CANCELLED = `${type}_CANCELLED`;
return {
BASE,
STARTED,
SUCCEEDED,
ERRORED,
CANCELLED,
start: meta => ({
type: STARTED,
meta,
}),
success: (payload, meta) => ({
type: SUCCEEDED,
payload,
meta,
}),
error: (caught, meta) => ({
type: ERRORED,
error: true,
payload: caught,
meta,
}),
cancel: meta => ({
type: CANCELLED,
meta,
}),
};
}
export function* requestSaga(type, callable, meta) {
const { start, success, error, cancel } = type;
yield put(start(meta));
try {
const payload = yield call(callable);
yield put(success(payload, meta));
return payload;
} catch (caught) {
return yield put(error(caught, meta));
} finally {
if (yield cancelled()) {
yield put(cancel(meta));
}
}
}
export function request(type, callable, meta) {
return call(requestSaga, type, callable, meta);
}

Below is a breakdown of how to use these functions.

Using the request helper

Creating types

Instead of writing:

export const FETCH_DOCUMENTS = 'documents/FETCH_DOCUMENTS'export const REQUEST_DOCUMENTS_STARTED = 'documents/REQUEST_DOCUMENTS_STARTED'export const REQUEST_DOCUMENTS_SUCCEEDED = 'documents/REQUEST_DOCUMENTS_SUCCEEDED'export const REQUEST_DOCUMENTS_ERRORED = 'documents/REQUEST_DOCUMENTS_ERRORED'export const REQUEST_DOCUMENTS_CANCELLED = 'documents/REQUEST_DOCUMENTS_CANCELLED'

We can instead now write:

export const FETCH = 'section/FETCH'
export const REQUEST_DOCUMENTS = createRequestTypes('section/REQUEST_DOCUMENTS')

createRequestTypes creates all the states we need for a common request (START, SUCCEEDED…) so we don’t need to explicitly declare each type.

Saga code for requests

Writing code to make a request then just becomes:

// worker saga
export function* requestDocuments() {
yield request(GET_DOCUMENTS, getDocuments)
}
// watcher saga
export function documentsSaga() {
yield fork(takeEvery, FETCH, requestDocuments)
}

The reducer

If the request is successful, no more saga code is needed to put or dispatch an action. Instead, in our reducer, we can write:

switch (action.type) {
case REQUEST_DEPENDENTS.SUCCEEDED:
return {
...state,
data: action.payload
};
case REQUEST_DEPENDENTS.ERRORED:
// ...error handling
}

Error Handling

Once you’ve used the request module we created above, it’s easy to write worker sagas. These worker sagas will handle if the API returned an error:

// inside of watcher saga
export function* handleError(error) {
yield put(showToastError(error))
}
// watcher saga
export function documentsSaga() {
yield fork(takeEvery, FETCH, requestDocuments)
yield fork(takeEvery, REQUEST_DEPENDENTS.ERRORED, handleError)
}

There is a nice thing about making requests and handling errors this way. Each worker saga does one thing (make a request, or handles an error) instead of many things.

Creating a service layer

Remember that service we called earlier, getDocuments? Well, that’s actually in our common/services folder:

// common/services/get-documents.js
export function getDocuments(params) {
// api request goes here
}

Service functions tend to be generator functions or functions that return promises.

The best part about creating a service layer on the front end is, we can write a script to iterate over our services, and — tada! We have documented actual API that’s in use by the front-end.

Passing data from the initial action to the reducer

Another convenience is the meta parameter.

export function request(type, callable, meta) {
return call(requestSaga, type, callable, meta);
}

The meta parameter gets passed along to the start, success, error, and cancel action creators.

You can use this feature to pass arguments that you passed into the request over to a reducer function, along with the response.

This is useful when you have some data that you need to persist from the beginning of a request and into the redux state.

Unmounting, Component Lifecycles, and Cancellation

Sometimes you have a component that starts fetching on mount, but if the component unmounts, you want to cancel that network request.

This is because when using redux, you don’t want to accidentally update your store with stale state. For example, a response from the server that exists for a component that isn’t on the page anymore — this is something you don’t want in your redux state.

So, we’re not telling the server to cancel the request, but if the response comes back successfully, we’re telling redux to not update our state with a response we don’t need anymore.

export const createLifecycleTypes = type => {
const BASE = type;
const VISITED = `${type}_VISITED`;
const EXITED = `${type}_EXITED`;
return {
BASE,
VISITED,
EXITED,
visit: props => ({ type: VISITED, payload: { props } }),
exit: props => ({ type: EXITED, payload: { props } }),
};
};

Creating lifecycle types

To create action types for redux, we use the createLifecycleTypes function:

export const PROFILE_LIFECYCLE = createLifecycleTypes('section/PROFILE_LIFECYCLE'

Connecting lifecycle actions to a component

After this, connect a component to redux so we can track its lifecycle activity:

connect(mapStateToProps, {
visit: PROFILE_LIFECYCLE.visit,
exit: PROFILE_LIFECYCLE.exit,
})(ProfilePage)

Dispatching lifecycle actions

On componentDidMount and componentWillUnmount, we can track if the component is on the page by doing:

componentDidMount() {
this.props.visit();
},
componentWillUnmount() {
this.props.exit();
},

Canceling a saga on lifecycle changes

Once we are listening for lifecycle changes, canceling becomes straightforward:

export function* employerProfileSaga() {
while (yield take(PROFILE_LIFECYCLE.VISITED)) {
yield call(initialize);
const task = yield fork(takeLatest, GET_DOCUMENTS, getDocuments);
yield take(PROFILE_LIFECYCLE.EXITED);
yield cancel(task);
}
}

With this logic, the loop will mostly only ever iterate once or twice, until the component is unmounted. There is only one fork created per mount + unmount of the component.

Hopefully, if you are using redux-saga, these modules will help you reduce the amount of boilerplate code that you write, and lead to less effort with regards to writing your requests and tests. If you have other ideas to make writing sagas concisely, please leave a comment — we’d be happy to hear about it! We’re also hiring across dozens of roles at League, check out our open opportunities!

--

--